From 3c2c4f29ea3f8ae61d52d5c913c0c3715b46b0f6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 14:26:48 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20focas-f1c=20=E2=80=94=20Modal=20codes?= =?UTF-8?q?=20+=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #259 Adds Modal/ + Override/ fixed-tree subfolders per FOCAS device, mirroring the pattern established by Status/ (#257) and Production/ (#258): cached snapshots refreshed on the probe tick, served from cache on read, no extra wire traffic on top of user-driven tag reads. Modal/ surfaces the four universally-present aux modal codes M/S/T/B from cnc_modal(type=100..103) as Int16. **G-group decoding (groups 1..21) is deferred to a follow-up** — the FWLIB ODBMDL union differs per series + group and the issue body explicitly permits this scoping. Adds the cnc_modal P/Invoke + ODBMDL struct + a generic int16 cnc_rdparam helper so the follow-up can add G-groups without further wire-level scaffolding. Override/ surfaces Feed/Rapid/Spindle/Jog from cnc_rdparam at MTB-specific parameter numbers (FocasDeviceOptions.OverrideParameters; defaults to 30i: 6010/6011/6014/6015). Per-field nullable params let a deployment hide overrides their MTB doesn't wire up; passing OverrideParameters=null suppresses the entire Override/ subfolder for that device. 6 unit tests cover discovery shape, omitted Override folder when unconfigured, partial Override field selection, cached-snapshot reads (Modal + Override), BadCommunicationError before first refresh, and the FwlibFocasClient disconnected short-circuit. --- .../FocasDriver.cs | 181 ++++++++++++++ .../FocasDriverOptions.cs | 6 +- .../FwlibFocasClient.cs | 55 +++++ .../FwlibNative.cs | 29 +++ .../IFocasClient.cs | 62 +++++ .../FocasModalOverrideFixedTreeTests.cs | 231 ++++++++++++++++++ 6 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasModalOverrideFixedTreeTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 3067388..d69f91d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -28,6 +28,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _productionNodesByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _modalNodesByName = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _overrideNodesByName = + new(StringComparer.OrdinalIgnoreCase); private DriverHealth _health = new(DriverState.Unknown, null, null); /// @@ -50,6 +54,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, "PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds", ]; + /// + /// Names of the active modal aux-code child nodes per device — M/S/T/B from + /// cnc_modal(type=100..103) (issue #259). G-group decoding is a deferred + /// follow-up because the FWLIB ODBMDL union varies per series + group. + /// + private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"]; + + /// + /// Names of the four operator-override child nodes per device — Feed / Rapid / + /// Spindle / Jog from cnc_rdparam with MTB-specific parameter numbers + /// (issue #259). A device whose FocasOverrideParameters entry is null for a + /// given field has the matching node omitted from the address space. + /// + private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"]; + public event EventHandler? OnDataChange; public event EventHandler? OnHostStatusChanged; @@ -110,6 +129,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, foreach (var field in ProductionFieldNames) _productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] = (device.Options.HostAddress, field); + foreach (var field in ModalFieldNames) + _modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] = + (device.Options.HostAddress, field); + if (device.Options.OverrideParameters is { } op) + { + foreach (var field in OverrideFieldNames) + { + if (OverrideParamFor(op, field) is null) continue; + _overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] = + (device.Options.HostAddress, field); + } + } } if (_options.Probe.Enabled) @@ -151,6 +182,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _tagsByName.Clear(); _statusNodesByName.Clear(); _productionNodesByName.Clear(); + _modalNodesByName.Clear(); + _overrideNodesByName.Clear(); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } @@ -191,6 +224,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, continue; } + // Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots + // refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/. + if (_modalNodesByName.TryGetValue(reference, out var modalKey)) + { + results[i] = ReadModalField(modalKey.Host, modalKey.Field, now); + continue; + } + if (_overrideNodesByName.TryGetValue(reference, out var overrideKey)) + { + results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now); + continue; + } + if (!_tagsByName.TryGetValue(reference, out var def)) { results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); @@ -347,6 +393,48 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, IsAlarm: false, WriteIdempotent: false)); } + + // Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally- + // present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group + // surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies + // per series + group (issue #259, plan PR F1-c). + var modalFolder = deviceFolder.Folder("Modal", "Modal"); + foreach (var field in ModalFieldNames) + { + var fullRef = ModalReferenceFor(device.HostAddress, field); + modalFolder.Variable(field, field, new DriverAttributeInfo( + FullName: fullRef, + DriverDataType: DriverDataType.Int16, + IsArray: false, + ArrayDim: null, + SecurityClass: SecurityClassification.ViewOnly, + IsHistorized: false, + IsAlarm: false, + WriteIdempotent: false)); + } + + // Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from + // cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when + // OverrideParameters is null; per-field nodes whose parameter is null are + // omitted so a deployment can hide overrides their MTB doesn't wire up. + if (device.OverrideParameters is { } overrideParams) + { + var overrideFolder = deviceFolder.Folder("Override", "Override"); + foreach (var field in OverrideFieldNames) + { + if (OverrideParamFor(overrideParams, field) is null) continue; + var fullRef = OverrideReferenceFor(device.HostAddress, field); + overrideFolder.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; } @@ -357,6 +445,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, private static string ProductionReferenceFor(string hostAddress, string field) => $"{hostAddress}::Production/{field}"; + private static string ModalReferenceFor(string hostAddress, string field) => + $"{hostAddress}::Modal/{field}"; + + private static string OverrideReferenceFor(string hostAddress, string field) => + $"{hostAddress}::Override/{field}"; + + private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch + { + "Feed" => p.FeedParam, + "Rapid" => p.RapidParam, + "Spindle" => p.SpindleParam, + "Jog" => p.JogParam, + _ => null, + }; + private static short? PickStatusField(FocasStatusInfo s, string field) => field switch { "Tmmode" => s.Tmmode, @@ -380,6 +483,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, _ => null, }; + private static short? PickModalField(FocasModalInfo m, string field) => field switch + { + "MCode" => m.MCode, + "SCode" => m.SCode, + "TCode" => m.TCode, + "BCode" => m.BCode, + _ => null, + }; + + private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch + { + "Feed" => o.Feed, + "Rapid" => o.Rapid, + "Spindle" => o.Spindle, + "Jog" => o.Jog, + _ => null, + }; + // ---- ISubscribable (polling overlay via shared engine) ---- public Task SubscribeAsync( @@ -427,6 +548,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.LastProduction = production; state.LastProductionUtc = DateTime.UtcNow; } + // Modal aux M/S/T/B + per-device operator overrides — same best-effort + // policy as Status/ + Production/. Override snapshot is suppressed when + // the device has no OverrideParameters configured (issue #259). + var modal = await client.GetModalAsync(ct).ConfigureAwait(false); + if (modal is not null) + { + state.LastModal = modal; + state.LastModalUtc = DateTime.UtcNow; + } + if (state.Options.OverrideParameters is { } overrideParams) + { + var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false); + if (ov is not null) + { + state.LastOverride = ov; + state.LastOverrideUtc = DateTime.UtcNow; + } + } } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } @@ -465,6 +604,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, device.LastProductionUtc, now); } + private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now) + { + if (!_devices.TryGetValue(hostAddress, out var device)) + return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + if (device.LastModal is not { } snap) + return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); + var value = PickModalField(snap, field); + if (value is null) + return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + return new DataValueSnapshot((short)value, FocasStatusMapper.Good, + device.LastModalUtc, now); + } + + private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now) + { + if (!_devices.TryGetValue(hostAddress, out var device)) + return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + if (device.LastOverride is not { } snap) + return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now); + var value = PickOverrideField(snap, field); + if (value is null) + return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now); + return new DataValueSnapshot((short)value, FocasStatusMapper.Good, + device.LastOverrideUtc, now); + } + private void TransitionDeviceState(DeviceState state, HostState newState) { HostState old; @@ -536,6 +701,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, public FocasProductionInfo? LastProduction { get; set; } public DateTime LastProductionUtc { get; set; } + /// + /// Cached cnc_modal M/S/T/B snapshot, refreshed on every probe tick. + /// Reads of the per-device Modal/<field> nodes serve from this cache + /// so they don't pile extra wire traffic on top of user-driven reads (issue #259). + /// + public FocasModalInfo? LastModal { get; set; } + public DateTime LastModalUtc { get; set; } + + /// + /// Cached cnc_rdparam override snapshot, refreshed on every probe tick. + /// Suppressed when the device's + /// is null (no Override/ nodes are exposed in that case — issue #259). + /// + public FocasOverrideInfo? LastOverride { get; set; } + public DateTime LastOverrideUtc { get; set; } + public void DisposeClient() { Client?.Dispose(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs index ce8a042..2e995c1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs @@ -17,11 +17,15 @@ public sealed class FocasDriverOptions /// One CNC the driver talks to. enables per-series /// address validation at ; leave as /// to skip validation (legacy behaviour). +/// declares the four MTB-specific override +/// cnc_rdparam numbers surfaced under Override/; pass null to +/// suppress the entire Override/ subfolder for that device (issue #259). /// public sealed record FocasDeviceOptions( string HostAddress, string? DeviceName = null, - FocasCncSeries Series = FocasCncSeries.Unknown); + FocasCncSeries Series = FocasCncSeries.Unknown, + FocasOverrideParameters? OverrideParameters = null); /// /// One FOCAS-backed OPC UA variable. is the canonical FOCAS diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 737e41b..626e519 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -187,6 +187,61 @@ internal sealed class FwlibFocasClient : IFocasClient return true; } + private bool TryReadInt16Param(ushort number, out short value) + { + var buf = new FwlibNative.IODBPSD { Data = new byte[32] }; + var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf); + if (ret != 0) { value = 0; return false; } + value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data); + return true; + } + + public Task GetModalAsync(CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(null); + // type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0). + // Best-effort — if any single read fails we still surface the others as 0; the + // probe loop only updates the cache on a non-null return so a partial snapshot + // is preferable to throwing away every successful field. + return Task.FromResult(new FocasModalInfo( + MCode: ReadModalAux(type: 100), + SCode: ReadModalAux(type: 101), + TCode: ReadModalAux(type: 102), + BCode: ReadModalAux(type: 103))); + } + + private short ReadModalAux(short type) + { + var buf = new FwlibNative.ODBMDL { Data = new byte[8] }; + var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf); + if (ret != 0) return 0; + // For aux types (100..103) the union holds the code at offset 0 as a 2-byte + // value (aux_data). Reading as Int16 keeps the surface identical to the + // record contract; oversized values would have been truncated by FWLIB anyway. + return BinaryPrimitives.ReadInt16LittleEndian(buf.Data); + } + + public Task GetOverrideAsync( + FocasOverrideParameters parameters, CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult(null); + // Each parameter is independently nullable — a null parameter number keeps the + // corresponding field at null + skips the wire call. A successful read on at + // least one parameter is enough to publish a snapshot; this matches the + // best-effort policy used by GetProductionAsync (issue #259). + var feed = TryReadOverride(parameters.FeedParam); + var rapid = TryReadOverride(parameters.RapidParam); + var spindle = TryReadOverride(parameters.SpindleParam); + var jog = TryReadOverride(parameters.JogParam); + return Task.FromResult(new FocasOverrideInfo(feed, rapid, spindle, jog)); + } + + private short? TryReadOverride(ushort? param) + { + if (param is null) return null; + return TryReadInt16Param(param.Value, out var v) ? v : null; + } + // ---- PMC ---- private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index afea72d..c3a1852 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -99,6 +99,19 @@ internal static class FwlibNative [DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)] public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer); + // ---- Modal codes ---- + + /// + /// cnc_modal — read modal information for one G-group or auxiliary code. + /// : 1..21 = G-group N (single group), 100 = M, 101 = S, + /// 102 = T, 103 = B (per Fanuc FOCAS reference). : 0 = + /// active modal commands. We only consume types 100..103 today (M/S/T/B); the + /// G-group decode is deferred to a follow-up because the ODBMDL union + /// varies by group + series (issue #259). + /// + [DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)] + public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer); + // ---- Structs ---- /// @@ -151,6 +164,22 @@ internal static class FwlibNative public int Msec; } + /// + /// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we + /// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an + /// int aux_data at offset 0; we read the first short for symmetry with + /// the FWLIB g_modal.aux_data width on G-group reads. The G-group decode + /// (type=1..21) is deferred — see for context (issue #259). + /// + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct ODBMDL + { + public short Datano; + public short Type; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)] + public byte[] Data; + } + /// ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode. [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct ODBST diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index b90a374..4259c01 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -70,6 +70,27 @@ public interface IFocasClient : IDisposable /// Task GetProductionAsync(CancellationToken cancellationToken) => Task.FromResult(null); + + /// + /// Read the active modal M/S/T/B codes via cnc_modal. G-group decoding is + /// deferred — the FWLIB ODBMDL union differs per series + group and the + /// issue body permits surfacing only the universally-present M/S/T/B fields in + /// the first cut (issue #259). Returns null when the wire client cannot + /// supply the snapshot. + /// + Task GetModalAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + /// + /// Read the four operator override values (feed / rapid / spindle / jog) via + /// cnc_rdparam. The parameter numbers are MTB-specific so the caller passes + /// them in via ; a null entry suppresses that + /// field's read (the corresponding node is also omitted from the address space). + /// Returns null when the wire client cannot supply the snapshot (issue #259). + /// + Task GetOverrideAsync( + FocasOverrideParameters parameters, CancellationToken cancellationToken) + => Task.FromResult(null); } /// @@ -102,6 +123,47 @@ public sealed record FocasProductionInfo( int PartsTotal, int CycleTimeSeconds); +/// +/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a +/// deferred follow-up — the FWLIB ODBMDL union differs per series + group, and +/// the issue body permits the first cut to surface only the universally-present +/// M/S/T/B fields. short matches the FWLIB aux_data width. +/// +public sealed record FocasModalInfo( + short MCode, + short SCode, + short TCode, + short BCode); + +/// +/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259). +/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A +/// null entry suppresses that field's read on the wire and removes the matching +/// node from the address space; this lets a deployment hide overrides their MTB doesn't +/// wire up rather than always serving Bad. +/// +public sealed record FocasOverrideParameters( + ushort? FeedParam, + ushort? RapidParam, + ushort? SpindleParam, + ushort? JogParam) +{ + /// Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. + public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015); +} + +/// +/// Snapshot of the four operator overrides (issue #259). Each value is a percentage +/// surfaced as Int16; a value of null means the corresponding parameter +/// was not configured (suppressed at ). All four +/// fields nullable so the driver can omit nodes whose MTB parameter is unset. +/// +public sealed record FocasOverrideInfo( + short? Feed, + short? Rapid, + short? Spindle, + short? Jog); + /// Factory for s. One client per configured device. public interface IFocasClientFactory { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasModalOverrideFixedTreeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasModalOverrideFixedTreeTests.cs new file mode 100644 index 0000000..ce5819f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasModalOverrideFixedTreeTests.cs @@ -0,0 +1,231 @@ +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 FocasModalOverrideFixedTreeTests +{ + private const string Host = "focas://10.0.0.6:8193"; + + /// + /// Variant of that returns configurable + /// + snapshots. + /// + private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient + { + public FocasModalInfo? Modal { get; set; } + public FocasOverrideInfo? Override { get; set; } + public FocasOverrideParameters? LastOverrideParams { get; private set; } + + Task IFocasClient.GetModalAsync(CancellationToken ct) => + Task.FromResult(Modal); + + Task IFocasClient.GetOverrideAsync( + FocasOverrideParameters parameters, CancellationToken ct) + { + LastOverrideParams = parameters; + return Task.FromResult(Override); + } + } + + [Fact] + public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device() + { + var builder = new RecordingBuilder(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")], + Tags = [], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-modal", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal"); + var modalVars = builder.Variables.Where(v => + v.Info.FullName.Contains("::Modal/")).ToList(); + modalVars.Count.ShouldBe(4); + string[] expected = ["MCode", "SCode", "TCode", "BCode"]; + foreach (var name in expected) + { + var node = modalVars.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}::Modal/{name}"); + } + } + + [Fact] + public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured() + { + var builder = new RecordingBuilder(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null + Tags = [], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-no-overrides", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldNotContain(f => f.BrowseName == "Override"); + builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/")); + } + + [Fact] + public async Task DiscoverAsync_emits_only_configured_Override_fields() + { + // Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up. + var builder = new RecordingBuilder(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = + [ + new FocasDeviceOptions(Host, + OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)), + ], + Tags = [], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-partial-overrides", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.DiscoverAsync(builder, CancellationToken.None); + + builder.Folders.ShouldContain(f => f.BrowseName == "Override"); + var overrideVars = builder.Variables.Where(v => + v.Info.FullName.Contains("::Override/")).ToList(); + overrideVars.Count.ShouldBe(2); + overrideVars.ShouldContain(v => v.BrowseName == "Feed"); + overrideVars.ShouldContain(v => v.BrowseName == "Rapid"); + overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle"); + overrideVars.ShouldNotContain(v => v.BrowseName == "Jog"); + } + + [Fact] + public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot() + { + var fake = new ModalAwareFakeFocasClient + { + Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0), + Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25), + }; + var factory = new FakeFocasClientFactory { Customise = () => fake }; + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = + [ + new FocasDeviceOptions(Host, + OverrideParameters: FocasOverrideParameters.Default), + ], + Tags = [], + Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, + }, "drv-modal-read", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Wait for at least one probe tick to populate both caches. + await WaitForAsync(async () => + { + var snap = (await drv.ReadAsync( + [$"{Host}::Modal/MCode"], CancellationToken.None)).Single(); + return snap.StatusCode == FocasStatusMapper.Good; + }, TimeSpan.FromSeconds(3)); + + var refs = new[] + { + $"{Host}::Modal/MCode", + $"{Host}::Modal/SCode", + $"{Host}::Modal/TCode", + $"{Host}::Modal/BCode", + $"{Host}::Override/Feed", + $"{Host}::Override/Rapid", + $"{Host}::Override/Spindle", + $"{Host}::Override/Jog", + }; + var snaps = await drv.ReadAsync(refs, CancellationToken.None); + + snaps[0].Value.ShouldBe((short)8); + snaps[1].Value.ShouldBe((short)1200); + snaps[2].Value.ShouldBe((short)101); + snaps[3].Value.ShouldBe((short)0); + snaps[4].Value.ShouldBe((short)100); + snaps[5].Value.ShouldBe((short)50); + snaps[6].Value.ShouldBe((short)110); + snaps[7].Value.ShouldBe((short)25); + foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good); + // The driver hands the device's configured override parameters to the wire client + // verbatim — defaulting to 30i numbers. + fake.LastOverrideParams.ShouldNotBeNull(); + fake.LastOverrideParams!.FeedParam.ShouldBe(6010); + fake.LastOverrideParams.RapidParam.ShouldBe(6011); + + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty() + { + // Probe disabled — neither modal nor override caches populate; the nodes still + // resolve as known references but report Bad until the first successful poll. + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = + [ + new FocasDeviceOptions(Host, + OverrideParameters: FocasOverrideParameters.Default), + ], + Tags = [], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-empty-cache", new FakeFocasClientFactory()); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snaps = await drv.ReadAsync( + [$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None); + snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); + } + + [Fact] + public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected() + { + // Construction is licence-safe (no DLL load); the unconnected client must short- + // circuit before P/Invoke. Returns null → driver leaves the cache untouched. + var client = new FwlibFocasClient(); + (await client.GetModalAsync(CancellationToken.None)).ShouldBeNull(); + (await client.GetOverrideAsync( + FocasOverrideParameters.Default, CancellationToken.None)).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) { } } + } +} -- 2.49.1