diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index b04c422..680d18a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -24,8 +24,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly PollGroupEngine _poll; private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _statusNodesByName = + new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); + /// + /// Names of the 9 fixed-tree Status/ child nodes per device, mirroring the 9 + /// fields of Fanuc's cnc_rdcncstat ODBST struct (issue #257). Order matters for + /// deterministic discovery output. + /// + private static readonly string[] StatusFieldNames = + [ + "Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy", + ]; + public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; @@ -76,6 +88,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _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) { foreach (var state in _devices.Values) @@ -113,6 +134,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } _devices.Clear(); _tagsByName.Clear(); + _statusNodesByName.Clear(); _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++) { 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)) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); @@ -257,10 +288,44 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, IsAlarm: false, 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; } + 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) ---- public Task SubscribeAsync( @@ -287,6 +352,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, { var client = await EnsureConnectedAsync(state, 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 { /* 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) { HostState old; @@ -352,6 +443,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow; public CancellationTokenSource? ProbeCts { get; set; } + /// + /// Cached cnc_rdcncstat snapshot, refreshed on every probe tick. Reads of + /// the per-device Status/<field> fixed-tree nodes serve from this cache + /// so they don't pile extra wire traffic on top of the user-driven tag reads. + /// + public FocasStatusInfo? LastStatus { get; set; } + public DateTime LastStatusUtc { get; set; } + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 63a55fd..af0a24e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -137,6 +137,24 @@ internal sealed class FwlibFocasClient : IFocasClient return Task.FromResult(ret == 0); } + public Task GetStatusAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(null); + var buf = new FwlibNative.ODBST(); + var ret = FwlibNative.StatInfo(_handle, ref buf); + if (ret != 0) return Task.FromResult(null); + return Task.FromResult(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 ---- private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 4c7733a..38f55ba 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -48,8 +48,37 @@ public interface IFocasClient : IDisposable /// responds with any valid status. /// Task ProbeAsync(CancellationToken cancellationToken); + + /// + /// Read the full cnc_rdcncstat ODBST struct (9 small-int status flags). The + /// boolean is preserved for cheap reachability checks; this + /// method exposes the per-field detail used by the FOCAS driver's Status/ + /// fixed-tree nodes (see issue #257). Returns null 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. + /// + Task GetStatusAsync(CancellationToken cancellationToken) + => Task.FromResult(null); } +/// +/// Snapshot of the 9 fields returned by Fanuc's cnc_rdcncstat (ODBST). All fields +/// are short per the FWLIB header — small enums whose meaning is documented in the +/// Fanuc FOCAS reference (e.g. emergency: 0=released, 1=stop, 2=reset). Surfaced as +/// Int16 in the OPC UA address space rather than mapped enums so operators see +/// exactly what the CNC reported. +/// +public sealed record FocasStatusInfo( + short Dummy, + short Tmmode, + short Aut, + short Run, + short Motion, + short Mstb, + short EmergencyStop, + short Alarm, + short Edit); + /// Factory for s. One client per configured device. public interface IFocasClientFactory { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs index 2c002a6..ddd23b4 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityTests.cs @@ -31,8 +31,10 @@ public sealed class FocasCapabilityTests builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS"); 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); - builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); + // Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different + // 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 ---- diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFixedTreeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFixedTreeTests.cs new file mode 100644 index 0000000..f837243 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFixedTreeTests.cs @@ -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"; + + /// + /// Variant of that returns a configurable + /// snapshot from . Probe + /// keeps its existing boolean semantic so the back-compat path stays exercised. + /// + 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 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> 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) { } } + } +}