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();
+ }
+}