Merge pull request '[focas] FOCAS — ODBST status flags as fixed-tree nodes' (#325) from auto/focas/F1-a into auto/driver-gaps
This commit was merged in pull request #325.
This commit is contained in:
@@ -24,8 +24,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Names of the 9 fixed-tree <c>Status/</c> child nodes per device, mirroring the 9
|
||||||
|
/// fields of Fanuc's <c>cnc_rdcncstat</c> ODBST struct (issue #257). Order matters for
|
||||||
|
/// deterministic discovery output.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] StatusFieldNames =
|
||||||
|
[
|
||||||
|
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
|
||||||
|
];
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||||
|
|
||||||
@@ -76,6 +88,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
_tagsByName[tag.Name] = tag;
|
_tagsByName[tag.Name] = tag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-device fixed-tree Status nodes — issue #257. Names are deterministic so
|
||||||
|
// ReadAsync can dispatch on the synthetic full-reference without extra metadata.
|
||||||
|
foreach (var device in _devices.Values)
|
||||||
|
{
|
||||||
|
foreach (var field in StatusFieldNames)
|
||||||
|
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
|
||||||
|
(device.Options.HostAddress, field);
|
||||||
|
}
|
||||||
|
|
||||||
if (_options.Probe.Enabled)
|
if (_options.Probe.Enabled)
|
||||||
{
|
{
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
@@ -113,6 +134,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
_devices.Clear();
|
_devices.Clear();
|
||||||
_tagsByName.Clear();
|
_tagsByName.Clear();
|
||||||
|
_statusNodesByName.Clear();
|
||||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +158,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
for (var i = 0; i < fullReferences.Count; i++)
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
{
|
{
|
||||||
var reference = fullReferences[i];
|
var reference = fullReferences[i];
|
||||||
|
|
||||||
|
// Fixed-tree Status/ nodes — served from the per-device cached ODBST struct
|
||||||
|
// refreshed on the probe tick (issue #257). No wire call here.
|
||||||
|
if (_statusNodesByName.TryGetValue(reference, out var statusKey))
|
||||||
|
{
|
||||||
|
results[i] = ReadStatusField(statusKey.Host, statusKey.Field, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
@@ -257,10 +288,44 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
WriteIdempotent: tag.WriteIdempotent));
|
WriteIdempotent: tag.WriteIdempotent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed-tree Status/ subfolder — 9 read-only Int16 nodes mirroring the ODBST
|
||||||
|
// fields (issue #257). Cached on the probe tick + served from DeviceState.LastStatus.
|
||||||
|
var statusFolder = deviceFolder.Folder("Status", "Status");
|
||||||
|
foreach (var field in StatusFieldNames)
|
||||||
|
{
|
||||||
|
var fullRef = StatusReferenceFor(device.HostAddress, field);
|
||||||
|
statusFolder.Variable(field, field, new DriverAttributeInfo(
|
||||||
|
FullName: fullRef,
|
||||||
|
DriverDataType: DriverDataType.Int16,
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: SecurityClassification.ViewOnly,
|
||||||
|
IsHistorized: false,
|
||||||
|
IsAlarm: false,
|
||||||
|
WriteIdempotent: false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string StatusReferenceFor(string hostAddress, string field) =>
|
||||||
|
$"{hostAddress}::Status/{field}";
|
||||||
|
|
||||||
|
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
||||||
|
{
|
||||||
|
"Tmmode" => s.Tmmode,
|
||||||
|
"Aut" => s.Aut,
|
||||||
|
"Run" => s.Run,
|
||||||
|
"Motion" => s.Motion,
|
||||||
|
"Mstb" => s.Mstb,
|
||||||
|
"EmergencyStop" => s.EmergencyStop,
|
||||||
|
"Alarm" => s.Alarm,
|
||||||
|
"Edit" => s.Edit,
|
||||||
|
"Dummy" => s.Dummy,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||||
|
|
||||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
@@ -287,6 +352,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
{
|
{
|
||||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
// Refresh the cached ODBST status snapshot on every probe tick — this is
|
||||||
|
// what the Status/ fixed-tree nodes serve from. Best-effort: a null result
|
||||||
|
// (older IFocasClient impls without GetStatusAsync) just leaves the cache
|
||||||
|
// unchanged so the previous good snapshot keeps serving until refreshed.
|
||||||
|
var snapshot = await client.GetStatusAsync(ct).ConfigureAwait(false);
|
||||||
|
if (snapshot is not null)
|
||||||
|
{
|
||||||
|
state.LastStatus = snapshot;
|
||||||
|
state.LastStatusUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||||
catch { /* connect-failure path already disposed + cleared the client */ }
|
catch { /* connect-failure path already disposed + cleared the client */ }
|
||||||
@@ -298,6 +376,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DataValueSnapshot ReadStatusField(string hostAddress, string field, DateTime now)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||||
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
|
if (device.LastStatus is not { } snap)
|
||||||
|
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||||
|
var value = PickStatusField(snap, field);
|
||||||
|
if (value is null)
|
||||||
|
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
|
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||||
|
device.LastStatusUtc, now);
|
||||||
|
}
|
||||||
|
|
||||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||||
{
|
{
|
||||||
HostState old;
|
HostState old;
|
||||||
@@ -352,6 +443,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||||
public CancellationTokenSource? ProbeCts { get; set; }
|
public CancellationTokenSource? ProbeCts { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cached <c>cnc_rdcncstat</c> snapshot, refreshed on every probe tick. Reads of
|
||||||
|
/// the per-device <c>Status/<field></c> fixed-tree nodes serve from this cache
|
||||||
|
/// so they don't pile extra wire traffic on top of the user-driven tag reads.
|
||||||
|
/// </summary>
|
||||||
|
public FocasStatusInfo? LastStatus { get; set; }
|
||||||
|
public DateTime LastStatusUtc { get; set; }
|
||||||
|
|
||||||
public void DisposeClient()
|
public void DisposeClient()
|
||||||
{
|
{
|
||||||
Client?.Dispose();
|
Client?.Dispose();
|
||||||
|
|||||||
@@ -137,6 +137,24 @@ internal sealed class FwlibFocasClient : IFocasClient
|
|||||||
return Task.FromResult(ret == 0);
|
return Task.FromResult(ret == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
|
||||||
|
var buf = new FwlibNative.ODBST();
|
||||||
|
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||||
|
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
|
||||||
|
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
|
||||||
|
Dummy: buf.Dummy,
|
||||||
|
Tmmode: buf.TmMode,
|
||||||
|
Aut: buf.Aut,
|
||||||
|
Run: buf.Run,
|
||||||
|
Motion: buf.Motion,
|
||||||
|
Mstb: buf.Mstb,
|
||||||
|
EmergencyStop: buf.Emergency,
|
||||||
|
Alarm: buf.Alarm,
|
||||||
|
Edit: buf.Edit));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- PMC ----
|
// ---- PMC ----
|
||||||
|
|
||||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||||
|
|||||||
@@ -48,8 +48,37 @@ public interface IFocasClient : IDisposable
|
|||||||
/// responds with any valid status.
|
/// responds with any valid status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
|
||||||
|
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
|
||||||
|
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
|
||||||
|
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
|
||||||
|
/// supply the struct (e.g. transport/IPC variant where the contract has not been
|
||||||
|
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
|
||||||
|
/// </summary>
|
||||||
|
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<FocasStatusInfo?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
|
||||||
|
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
|
||||||
|
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
|
||||||
|
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
|
||||||
|
/// exactly what the CNC reported.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FocasStatusInfo(
|
||||||
|
short Dummy,
|
||||||
|
short Tmmode,
|
||||||
|
short Aut,
|
||||||
|
short Run,
|
||||||
|
short Motion,
|
||||||
|
short Mstb,
|
||||||
|
short EmergencyStop,
|
||||||
|
short Alarm,
|
||||||
|
short Edit);
|
||||||
|
|
||||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||||
public interface IFocasClientFactory
|
public interface IFocasClientFactory
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ public sealed class FocasCapabilityTests
|
|||||||
|
|
||||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
||||||
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
// Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different
|
||||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
// parent folders — disambiguate by FullName, which is unique per node.
|
||||||
|
builder.Variables.Single(v => v.Info.FullName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||||
|
builder.Variables.Single(v => v.Info.FullName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ISubscribable ----
|
// ---- ISubscribable ----
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasStatusFixedTreeTests
|
||||||
|
{
|
||||||
|
private const string Host = "focas://10.0.0.5:8193";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
||||||
|
/// <see cref="FocasStatusInfo"/> snapshot from <see cref="GetStatusAsync"/>. Probe
|
||||||
|
/// keeps its existing boolean semantic so the back-compat path stays exercised.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class StatusAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||||
|
{
|
||||||
|
public FocasStatusInfo? Status { get; set; }
|
||||||
|
|
||||||
|
// Shadow the default interface implementation with a real one. Explicit interface
|
||||||
|
// form so callers via IFocasClient hit this override; FakeFocasClient itself
|
||||||
|
// doesn't declare a virtual GetStatusAsync (the contract has a default impl).
|
||||||
|
Task<FocasStatusInfo?> IFocasClient.GetStatusAsync(CancellationToken ct) =>
|
||||||
|
Task.FromResult(Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_emits_Status_folder_with_9_Int16_nodes_per_device()
|
||||||
|
{
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
|
||||||
|
Tags = [],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", new FakeFocasClientFactory());
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||||
|
|
||||||
|
builder.Folders.ShouldContain(f => f.BrowseName == "Status" && f.DisplayName == "Status");
|
||||||
|
var statusVars = builder.Variables.Where(v =>
|
||||||
|
v.Info.FullName.Contains("::Status/")).ToList();
|
||||||
|
statusVars.Count.ShouldBe(9);
|
||||||
|
string[] expected = ["Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy"];
|
||||||
|
foreach (var name in expected)
|
||||||
|
{
|
||||||
|
var node = statusVars.SingleOrDefault(v => v.BrowseName == name);
|
||||||
|
node.BrowseName.ShouldBe(name);
|
||||||
|
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||||
|
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||||
|
node.Info.FullName.ShouldBe($"{Host}::Status/{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_serves_each_Status_field_from_cached_ODBST_snapshot()
|
||||||
|
{
|
||||||
|
var fake = new StatusAwareFakeFocasClient
|
||||||
|
{
|
||||||
|
Status = new FocasStatusInfo(
|
||||||
|
Dummy: 0, Tmmode: 1, Aut: 2, Run: 3, Motion: 4,
|
||||||
|
Mstb: 5, EmergencyStop: 1, Alarm: 7, Edit: 6),
|
||||||
|
};
|
||||||
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host)],
|
||||||
|
Tags = [],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Wait for at least one probe tick to populate the cache.
|
||||||
|
await WaitForAsync(async () =>
|
||||||
|
{
|
||||||
|
var snap = (await drv.ReadAsync(
|
||||||
|
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||||
|
return snap.StatusCode == FocasStatusMapper.Good;
|
||||||
|
}, TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
var refs = new[]
|
||||||
|
{
|
||||||
|
$"{Host}::Status/Tmmode",
|
||||||
|
$"{Host}::Status/Aut",
|
||||||
|
$"{Host}::Status/Run",
|
||||||
|
$"{Host}::Status/Motion",
|
||||||
|
$"{Host}::Status/Mstb",
|
||||||
|
$"{Host}::Status/EmergencyStop",
|
||||||
|
$"{Host}::Status/Alarm",
|
||||||
|
$"{Host}::Status/Edit",
|
||||||
|
$"{Host}::Status/Dummy",
|
||||||
|
};
|
||||||
|
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||||
|
|
||||||
|
snaps[0].Value.ShouldBe((short)1); // Tmmode
|
||||||
|
snaps[1].Value.ShouldBe((short)2); // Aut
|
||||||
|
snaps[2].Value.ShouldBe((short)3); // Run
|
||||||
|
snaps[3].Value.ShouldBe((short)4); // Motion
|
||||||
|
snaps[4].Value.ShouldBe((short)5); // Mstb
|
||||||
|
snaps[5].Value.ShouldBe((short)1); // EmergencyStop
|
||||||
|
snaps[6].Value.ShouldBe((short)7); // Alarm
|
||||||
|
snaps[7].Value.ShouldBe((short)6); // Edit
|
||||||
|
snaps[8].Value.ShouldBe((short)0); // Dummy
|
||||||
|
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_returns_BadCommunicationError_when_status_cache_is_empty()
|
||||||
|
{
|
||||||
|
// Probe disabled — cache never populates; the status nodes still resolve as
|
||||||
|
// known references but report Bad until the first successful poll lands.
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host)],
|
||||||
|
Tags = [],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
}, "drv-1", new FakeFocasClientFactory());
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var snaps = await drv.ReadAsync(
|
||||||
|
[$"{Host}::Status/Tmmode"], CancellationToken.None);
|
||||||
|
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Existing_boolean_probe_path_still_works_alongside_GetStatusAsync()
|
||||||
|
{
|
||||||
|
// Back-compat guard: ProbeAsync's existing boolean contract is preserved. A client
|
||||||
|
// that doesn't override GetStatusAsync (default null) leaves the cache untouched
|
||||||
|
// but the probe still flips host state to Running.
|
||||||
|
var fake = new FakeFocasClient { ProbeResult = true };
|
||||||
|
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||||
|
var drv = new FocasDriver(new FocasDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(Host)],
|
||||||
|
Tags = [],
|
||||||
|
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||||
|
}, "drv-1", factory);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
await WaitForAsync(() => Task.FromResult(
|
||||||
|
drv.GetHostStatuses().Any(h => h.State == HostState.Running)),
|
||||||
|
TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
// No GetStatusAsync override → cache stays empty → status nodes report Bad,
|
||||||
|
// but the rest of the driver keeps functioning.
|
||||||
|
var snap = (await drv.ReadAsync(
|
||||||
|
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||||
|
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FwlibFocasClient_GetStatusAsync_returns_null_when_disconnected()
|
||||||
|
{
|
||||||
|
// Construction is licence-safe (no DLL load); calling GetStatusAsync on the
|
||||||
|
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
|
||||||
|
// in its current state.
|
||||||
|
var client = new FwlibFocasClient();
|
||||||
|
var result = await client.GetStatusAsync(CancellationToken.None);
|
||||||
|
result.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (!await condition() && DateTime.UtcNow < deadline)
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||||
|
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{ Folders.Add((browseName, displayName)); return this; }
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||||
|
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||||
|
|
||||||
|
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||||
|
|
||||||
|
private sealed class Handle(string fullRef) : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => fullRef;
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||||
|
}
|
||||||
|
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user