From 451b37a632331f334e4881f629b8895ea38d9aa7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 19:31:49 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20focas-f2a=20=E2=80=94=20DIAG:=20address?= =?UTF-8?q?=20scheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New FocasAreaKind.Diagnostic parsed from DIAG:nnn (whole-CNC) and DIAG:nnn/axis (per-axis), validated against a per-series FocasCapabilityMatrix.DiagnosticRange table (16i: 0-499; 0i-F family: 0-999; 30i/31i/32i: 0-1023; Power Motion i: 0-255; Unknown: permissive per existing matrix convention). IFocasClient gains ReadDiagnosticAsync(diagNumber, axisOrZero, type, ct) with a default returning BadNotSupported so older transport variants degrade gracefully. FwlibFocasClient implements it via a new cnc_rddiag P/Invoke that reuses the IODBPSD struct (same shape as cnc_rdparam). FocasDriver.ReadAsync dispatches Diagnostic addresses through the new path; non-Diagnostic kinds keep the existing ReadAsync route unchanged. Tests: parser positives (DIAG:1031, DIAG:280/2, case-insensitive, zero, axis-8) + negatives (malformed, axis>31), capability matrix boundaries per series, driver-level dispatch verifying axis index threads through, init-time rejection on out-of-range, and BadNotSupported fallback when the wire client doesn't override the default. 266/266 pass in Driver.FOCAS.Tests. Closes #263 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FocasAddress.cs | 24 +- .../FocasCapabilityMatrix.cs | 40 +++- .../FocasDriver.cs | 7 +- .../FocasDriverOptions.cs | 3 +- .../FwlibFocasClient.cs | 40 ++++ .../FwlibNative.cs | 19 ++ .../IFocasClient.cs | 14 ++ .../FakeFocasClient.cs | 13 ++ .../FocasDiagnosticAddressTests.cs | 213 ++++++++++++++++++ 9 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiagnosticAddressTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs index 1dba425..4c801e4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs @@ -1,17 +1,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// -/// Parsed FOCAS address covering the three addressing spaces a driver touches: +/// Parsed FOCAS address covering the four addressing spaces a driver touches: /// (letter + byte + optional bit — X0.0, R100, /// F20.3), (CNC parameter number — -/// PARAM:1020, PARAM:1815/0 for bit 0), and -/// (macro variable number — MACRO:100, MACRO:500). +/// PARAM:1020, PARAM:1815/0 for bit 0), +/// (macro variable number — MACRO:100, MACRO:500), and +/// (CNC diagnostic number, optionally per-axis — +/// DIAG:1031, DIAG:280/2) routed through cnc_rddiag. /// /// /// PMC letters: X/Y (IO), F/G (signals between PMC + CNC), R (internal /// relay), D (data table), C (counter), K (keep relay), A /// (message display), E (extended relay), T (timer). Byte numbering is 0-based; /// bit index when present is 0–7 and uses .N for PMC or /N for parameters. +/// Diagnostic addresses reuse the /N form to encode an axis index — BitIndex +/// carries the 1-based axis number (0 = whole-CNC diagnostic). /// public sealed record FocasAddress( FocasAreaKind Kind, @@ -28,6 +32,9 @@ public sealed record FocasAddress( ? $"PARAM:{Number}" : $"PARAM:{Number}/{BitIndex}", FocasAreaKind.Macro => $"MACRO:{Number}", + FocasAreaKind.Diagnostic => BitIndex is null or 0 + ? $"DIAG:{Number}" + : $"DIAG:{Number}/{BitIndex}", _ => $"?{Number}", }; @@ -42,6 +49,9 @@ public sealed record FocasAddress( if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase)) return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null); + if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase)) + return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/'); + // PMC path: letter + digits + optional .bit if (src.Length < 2 || !char.IsLetter(src[0])) return null; var letter = src[0..1].ToUpperInvariant(); @@ -92,4 +102,12 @@ public enum FocasAreaKind Pmc, Parameter, Macro, + /// + /// CNC diagnostic number routed through cnc_rddiag. DIAG:nnn is a + /// whole-CNC diagnostic (axis = 0); DIAG:nnn/axis is per-axis (axis is the + /// 1-based FANUC axis index). Like parameters, diagnostics span Int / Float / + /// Bit shapes — the driver picks the wire shape based on the configured tag's + /// . + /// + Diagnostic, } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs index 5ca176a..8d84b49 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs @@ -32,9 +32,10 @@ public static class FocasCapabilityMatrix return address.Kind switch { - FocasAreaKind.Macro => ValidateMacro(series, address.Number), - FocasAreaKind.Parameter => ValidateParameter(series, address.Number), - FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number), + FocasAreaKind.Macro => ValidateMacro(series, address.Number), + FocasAreaKind.Parameter => ValidateParameter(series, address.Number), + FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number), + FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number), _ => null, }; } @@ -73,6 +74,29 @@ public static class FocasCapabilityMatrix _ => (0, int.MaxValue), }; + /// + /// CNC diagnostic number range accepted by a series; from cnc_rddiag + /// (and cnc_rddiagdgn for axis-scoped reads). Returning null + /// means the series doesn't support cnc_rddiag at all — the driver + /// rejects every DIAG: address on that series. Conservative ceilings + /// per the FOCAS Developer Kit: legacy 16i-family caps at 499; modern 0i-F + /// family at 999; 30i / 31i / 32i extend to 1023. Power Motion i has a + /// narrow diagnostic surface (0..255). + /// + internal static (int min, int max)? DiagnosticRange(FocasCncSeries series) => series switch + { + FocasCncSeries.Sixteen_i => (0, 499), + FocasCncSeries.Zero_i_D => (0, 499), + FocasCncSeries.Zero_i_F or + FocasCncSeries.Zero_i_MF or + FocasCncSeries.Zero_i_TF => (0, 999), + FocasCncSeries.Thirty_i or + FocasCncSeries.ThirtyOne_i or + FocasCncSeries.ThirtyTwo_i => (0, 1023), + FocasCncSeries.PowerMotion_i => (0, 255), + _ => (0, int.MaxValue), + }; + /// PMC letters accepted per series. Legacy controllers omit F/M/C /// signal groups that 30i-family ladder programs use. internal static IReadOnlySet PmcLetters(FocasCncSeries series) => series switch @@ -143,6 +167,16 @@ public static class FocasCapabilityMatrix : null; } + private static string? ValidateDiagnostic(FocasCncSeries series, int number) + { + if (DiagnosticRange(series) is not { } range) + return $"Diagnostic addresses are not supported on {series} (no documented cnc_rddiag range)."; + var (min, max) = range; + return (number < min || number > max) + ? $"Diagnostic #{number} is outside the documented range [{min}, {max}] for {series}." + : null; + } + private static string? ValidatePmc(FocasCncSeries series, string? letter, int number) { if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix."; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 70d3a63..51b2264 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -154,7 +154,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var parsed = FocasAddress.TryParse(tag.Address) ?? throw new InvalidOperationException( $"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " + - $"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500."); + $"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500, DIAG:1031, DIAG:280/2."); if (_devices.TryGetValue(tag.DeviceHostAddress, out var device) && FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason) { @@ -377,7 +377,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false); var parsed = FocasAddress.TryParse(def.Address) ?? throw new InvalidOperationException($"FOCAS tag '{def.Name}' has malformed Address '{def.Address}'."); - var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); + var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic + ? await client.ReadDiagnosticAsync( + parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false) + : await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false); results[i] = new DataValueSnapshot(value, status, now, now); if (status == FocasStatusMapper.Good) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs index ad4f5c9..838a7b5 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs @@ -58,7 +58,8 @@ public sealed record FocasDeviceOptions( /// /// One FOCAS-backed OPC UA variable. is the canonical FOCAS /// address string that parses via — -/// X0.0 / R100 / PARAM:1815/0 / MACRO:500. +/// X0.0 / R100 / PARAM:1815/0 / MACRO:500 / +/// DIAG:1031 / DIAG:280/2. /// public sealed record FocasTagDefinition( string Name, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs index 3bb84c3..8a4d2b6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs @@ -59,10 +59,20 @@ internal sealed class FwlibFocasClient : IFocasClient FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)), FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)), FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)), + FocasAreaKind.Diagnostic => Task.FromResult( + ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)), _ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)), }; } + public Task<(object? value, uint status)> ReadDiagnosticAsync( + int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken) + { + if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError)); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(ReadDiagnostic(diagNumber, axisOrZero, type)); + } + public async Task WriteAsync( FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) { @@ -467,6 +477,36 @@ internal sealed class FwlibFocasClient : IFocasClient return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret); } + private (object? value, uint status) ReadDiagnostic(int diagNumber, int axisOrZero, FocasDataType type) + { + var buf = new FwlibNative.IODBPSD { Data = new byte[32] }; + var length = DiagnosticReadLength(type); + var ret = FwlibNative.RdDiag(_handle, (ushort)diagNumber, (short)axisOrZero, (short)length, ref buf); + if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret)); + + var value = type switch + { + FocasDataType.Bit => (object)ExtractBit(buf.Data[0], 0), + FocasDataType.Byte => (object)(sbyte)buf.Data[0], + FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data), + FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data), + FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data), + FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data), + _ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data), + }; + return (value, FocasStatusMapper.Good); + } + + private static int DiagnosticReadLength(FocasDataType type) => type switch + { + FocasDataType.Bit or FocasDataType.Byte => 4 + 1, + FocasDataType.Int16 => 4 + 2, + FocasDataType.Int32 => 4 + 4, + FocasDataType.Float32 => 4 + 4, + FocasDataType.Float64 => 4 + 8, + _ => 4 + 4, + }; + private (object? value, uint status) ReadMacro(FocasAddress address) { var buf = new FwlibNative.ODBM(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs index dae1158..ad6ba3b 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs @@ -176,6 +176,25 @@ internal static class FwlibNative ref short outCount, ref IODBAXIS figureinfo); + // ---- Diagnostics ---- + + /// + /// cnc_rddiag — read a CNC diagnostic value. is the + /// diagnostic number (e.g. 1031 = current alarm cause); is 0 + /// for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics. + /// is sized like — 4-byte header + + /// widest payload (8 bytes for Float64). The shape of the payload depends on the + /// diagnostic; the managed side decodes via on the + /// configured tag (issue #263). + /// + [DllImport(Library, EntryPoint = "cnc_rddiag", ExactSpelling = true)] + public static extern short RdDiag( + ushort handle, + ushort number, + short axis, + short length, + ref IODBPSD buffer); + // ---- Currently-executing block ---- /// diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 0b7d166..bd91c9d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -148,6 +148,20 @@ public interface IFocasClient : IDisposable /// Task?> GetFigureScalingAsync(CancellationToken cancellationToken) => Task.FromResult?>(null); + + /// + /// Read a CNC diagnostic value via cnc_rddiag. is + /// the diagnostic number (validated against + /// by ). + /// is 0 for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics. + /// The shape of the returned value depends on the diagnostic — Int / Float / Bit are + /// all possible. Returns null on default (transport variants that haven't yet + /// implemented diagnostics) so the driver falls back to BadNotSupported on those nodes + /// until the wire client is extended (issue #263). + /// + Task<(object? value, uint status)> ReadDiagnosticAsync( + int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken) + => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)); } /// diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index c15dbf3..4a10759 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -46,6 +46,19 @@ internal class FakeFocasClient : IFocasClient return Task.FromResult(status); } + public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new(); + + public virtual Task<(object? value, uint status)> ReadDiagnosticAsync( + int diagNumber, int axisOrZero, FocasDataType type, CancellationToken ct) + { + if (ThrowOnRead) throw Exception ?? new InvalidOperationException(); + DiagnosticReads.Add((diagNumber, axisOrZero, type)); + var key = axisOrZero == 0 ? $"DIAG:{diagNumber}" : $"DIAG:{diagNumber}/{axisOrZero}"; + var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good; + var value = Values.TryGetValue(key, out var v) ? v : null; + return Task.FromResult((value, status)); + } + public virtual Task ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult); public virtual void Dispose() diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiagnosticAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiagnosticAddressTests.cs new file mode 100644 index 0000000..c5ded60 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiagnosticAddressTests.cs @@ -0,0 +1,213 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// Coverage for the DIAG: address scheme — parser, capability matrix, +/// driver dispatch (issue #263, plan PR F2-a). DIAG: addresses route to +/// cnc_rddiag on the wire; the driver validates against +/// at init time + dispatches +/// reads through +/// at runtime. +/// +[Trait("Category", "Unit")] +public sealed class FocasDiagnosticAddressTests +{ + // ---- Parser positive ---- + + [Theory] + [InlineData("DIAG:1000", 1000, 0)] + [InlineData("DIAG:280/2", 280, 2)] + [InlineData("DIAG:0", 0, 0)] + [InlineData("diag:500", 500, 0)] // case-insensitive prefix + [InlineData("DIAG:1023/8", 1023, 8)] + public void TryParse_accepts_DIAG_forms(string input, int expectedNumber, int expectedAxis) + { + var parsed = FocasAddress.TryParse(input); + parsed.ShouldNotBeNull(); + parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic); + parsed.Number.ShouldBe(expectedNumber); + (parsed.BitIndex ?? 0).ShouldBe(expectedAxis); + } + + [Theory] + [InlineData("DIAG:abc")] + [InlineData("DIAG:")] + [InlineData("DIAG:-1")] + [InlineData("DIAG:100/-1")] + [InlineData("DIAG:100/99")] // axis > 31 (parser ceiling) + public void TryParse_rejects_malformed_DIAG(string input) + { + FocasAddress.TryParse(input).ShouldBeNull(); + } + + [Fact] + public void Canonical_round_trip_for_DIAG_whole_CNC() + { + var parsed = FocasAddress.TryParse("DIAG:1000"); + parsed!.Canonical.ShouldBe("DIAG:1000"); + } + + [Fact] + public void Canonical_round_trip_for_DIAG_per_axis() + { + var parsed = FocasAddress.TryParse("DIAG:280/2"); + parsed!.Canonical.ShouldBe("DIAG:280/2"); + } + + // ---- Capability matrix ---- + + [Theory] + [InlineData(FocasCncSeries.Thirty_i, 1023, true)] + [InlineData(FocasCncSeries.Thirty_i, 1024, false)] + [InlineData(FocasCncSeries.ThirtyOne_i, 500, true)] + [InlineData(FocasCncSeries.ThirtyTwo_i, 0, true)] + [InlineData(FocasCncSeries.Sixteen_i, 499, true)] + [InlineData(FocasCncSeries.Sixteen_i, 500, false)] // 16i caps lower + [InlineData(FocasCncSeries.Zero_i_F, 999, true)] + [InlineData(FocasCncSeries.Zero_i_F, 1000, false)] + [InlineData(FocasCncSeries.Zero_i_D, 280, true)] + [InlineData(FocasCncSeries.Zero_i_D, 600, false)] + [InlineData(FocasCncSeries.PowerMotion_i, 255, true)] + [InlineData(FocasCncSeries.PowerMotion_i, 256, false)] + public void Diagnostic_range_matches_series(FocasCncSeries series, int number, bool accepted) + { + var address = new FocasAddress(FocasAreaKind.Diagnostic, null, number, null); + var result = FocasCapabilityMatrix.Validate(series, address); + (result is null).ShouldBe(accepted, + $"DIAG:{number} on {series}: expected {(accepted ? "accept" : "reject")}, got '{result}'"); + } + + [Fact] + public void Unknown_series_accepts_any_diagnostic_number() + { + var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 99_999, null); + FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull(); + } + + [Fact] + public void Diagnostic_rejection_message_names_series_and_limit() + { + var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 5_000, null); + var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Sixteen_i, address); + reason.ShouldNotBeNull(); + reason.ShouldContain("5000"); + reason.ShouldContain("Sixteen_i"); + reason.ShouldContain("499"); + } + + // ---- Driver dispatch ---- + + private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver( + FocasCncSeries series, + params FocasTagDefinition[] tags) + { + var factory = new FakeFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: series)], + Tags = tags, + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-1", factory); + return (drv, factory); + } + + [Fact] + public async Task DIAG_read_routes_through_ReadDiagnosticAsync_with_axis_zero() + { + var (drv, factory) = NewDriver( + FocasCncSeries.Thirty_i, + new FocasTagDefinition("AlarmCause", "focas://10.0.0.5:8193", "DIAG:1000", FocasDataType.Int32)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => + { + var c = new FakeFocasClient(); + c.Values["DIAG:1000"] = 42; + return c; + }; + + var snapshots = await drv.ReadAsync(["AlarmCause"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots.Single().Value.ShouldBe(42); + var fake = factory.Clients.Single(); + fake.DiagnosticReads.Single().ShouldBe((1000, 0, FocasDataType.Int32)); + } + + [Fact] + public async Task DIAG_per_axis_read_threads_axis_index_through() + { + var (drv, factory) = NewDriver( + FocasCncSeries.Thirty_i, + new FocasTagDefinition("ServoLoad2", "focas://10.0.0.5:8193", "DIAG:280/2", FocasDataType.Int16)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => + { + var c = new FakeFocasClient(); + c.Values["DIAG:280/2"] = (short)17; + return c; + }; + + var snapshots = await drv.ReadAsync(["ServoLoad2"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good); + snapshots.Single().Value.ShouldBe((short)17); + factory.Clients.Single().DiagnosticReads.Single().ShouldBe((280, 2, FocasDataType.Int16)); + } + + [Fact] + public async Task DIAG_out_of_range_for_series_rejected_at_init() + { + var (drv, _) = NewDriver( + FocasCncSeries.Sixteen_i, + new FocasTagDefinition("Bad", "focas://10.0.0.5:8193", "DIAG:5000", FocasDataType.Int32)); + var ex = await Should.ThrowAsync( + () => drv.InitializeAsync("{}", CancellationToken.None)); + ex.Message.ShouldContain("5000"); + ex.Message.ShouldContain("Sixteen_i"); + } + + [Fact] + public async Task DIAG_default_interface_method_surfaces_BadNotSupported() + { + // Stand-in client that does NOT override ReadDiagnosticAsync — falls through to + // the IFocasClient default returning BadNotSupported. Models a transport variant + // (e.g. older IPC contract) that hasn't extended its wire surface to diagnostics. + var factory = new BareFocasClientFactory(); + var drv = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: FocasCncSeries.Unknown)], + Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "DIAG:100", FocasDataType.Int32)], + Probe = new FocasProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); + snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported); + } + + /// + /// Test stand-in that overrides every interface method we need EXCEPT + /// — exercising the default + /// implementation that returns BadNotSupported for transports that + /// haven't extended their wire surface yet. + /// + private sealed class FakeWithoutDiagnosticOverride : IFocasClient + { + public bool IsConnected { get; private set; } + public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) + { IsConnected = true; return Task.CompletedTask; } + public Task<(object? value, uint status)> ReadAsync( + FocasAddress address, FocasDataType type, CancellationToken ct) => + Task.FromResult<(object?, uint)>((null, FocasStatusMapper.Good)); + public Task WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) => + Task.FromResult(FocasStatusMapper.Good); + public Task ProbeAsync(CancellationToken ct) => Task.FromResult(true); + public void Dispose() { } + } + + private sealed class BareFocasClientFactory : IFocasClientFactory + { + public IFocasClient Create() => new FakeWithoutDiagnosticOverride(); + } +} -- 2.49.1