diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs index db407d18..53a23d2c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs @@ -467,18 +467,41 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional path ID override; defaults to . /// The raw parameter payload bytes, wrapped in a . public async Task> ReadParameterBytesAsync( - short dataNumber, + int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { using var callTimeout = CreateCallTimeout(cancellationToken, timeout); - var secondArg = axis == 0 ? dataNumber : axis; - var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false); + var block = await SendSingleRequestAsync(callTimeout.Token, ParameterRequest(dataNumber, axis, EffectivePathId(pathId))).ConfigureAwait(false); return ToResult(block, payload => payload); } + /// + /// The cnc_rdparam (0x000e) request block. The data number travels in the 32-bit + /// Arg1 UNNARROWED — the request block has always carried arguments, and + /// a short cast in the adapter once asked the CNC for a different (wrapped) number than the + /// one authored. A global parameter repeats the number in Arg2; an axis parameter carries + /// the axis there. + /// + /// The parameter data number. + /// Axis selector; zero means a global parameter. + /// The CNC path. + /// The request block. + internal static RequestBlock ParameterRequest(int dataNumber, short axis, ushort pathId) => + new(0x000e, dataNumber, axis == 0 ? dataNumber : axis, PathId: pathId); + + /// + /// The cnc_rdmacro (0x0015) request block: the variable number, unnarrowed, in both 32-bit + /// arguments (start / end of a one-variable range) — byte-identical to the live 31i-B capture for #500. + /// + /// The macro variable number. + /// The CNC path. + /// The request block. + internal static RequestBlock MacroRequest(int number, ushort pathId) => + new(0x0015, number, number, PathId: pathId); + /// Typed Int32 parameter read — convenience over . /// The parameter data number. /// The FOCAS parameter type code. @@ -487,7 +510,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional path ID override; defaults to . /// The typed parameter value, wrapped in a . public async Task> ReadParameterAsync( - short dataNumber, + int dataNumber, short type = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, @@ -507,7 +530,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed 8-bit parameter value, wrapped in a . - public async Task> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterByteAsync(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null @@ -522,7 +545,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed 16-bit parameter value, wrapped in a . - public async Task> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterInt16Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null @@ -537,7 +560,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed 32-bit parameter value, wrapped in a . - public async Task> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterInt32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null @@ -552,7 +575,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed single-precision parameter value, wrapped in a . - public async Task> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterFloat32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null || result.Value.Length < 4 @@ -567,7 +590,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional per-call timeout override. /// Optional path ID override; defaults to . /// The typed double-precision parameter value, wrapped in a . - public async Task> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) + public async Task> ReadParameterFloat64Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) { var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false); return !result.IsOk || result.Value is null || result.Value.Length < 8 @@ -582,14 +605,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// Optional path ID override; defaults to . /// The macro variable value, wrapped in a . public Task> ReadMacroAsync( - short number, + int number, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null) - => ReadSingleWithTimeoutAsync( - 0x0015, + { + var request = MacroRequest(number, EffectivePathId(pathId)); + return ReadSingleWithTimeoutAsync( + request.Command, payload => ParseMacro(number, payload), - cancellationToken, timeout, EffectivePathId(pathId), number, number); + cancellationToken, timeout, request.PathId, request.Arg1, request.Arg2); + } /// /// Decode a cnc_rdmacro (0x0015) payload. The on-wire ODBM record is 8 @@ -609,7 +635,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable /// The response-block payload. /// The decoded macro. /// The payload is shorter than 8 bytes. - internal static WireMacro ParseMacro(short number, byte[] payload) + internal static WireMacro ParseMacro(int number, byte[] payload) { RequireLength(payload, 8, "cnc_rdmacro"); return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6)); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs index ac6f32ee..70ca55f6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs @@ -170,7 +170,7 @@ public readonly record struct WireSpindleRecord(short Index, string Name); /// Parameter value returned by cnc_rdparam, interpreted as a scalar Int32. public sealed record WireParameter( - short DataNumber, + int DataNumber, short Type, int Value); @@ -184,7 +184,7 @@ public sealed record WireParameter( /// The 9-significant-digit mantissa. /// Implied decimal places; negative (0xFFFF on the wire) = vacant. public sealed record WireMacro( - short Number, + int Number, int Value, short Decimal) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs index d6d5a3a4..ec44e6e3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs @@ -80,6 +80,7 @@ public sealed class WireFocasClient : IFocasClient var area = FocasPmcAreaLookup.FromLetter(letter); if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown); var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType); + if (start < 0 || end > ushort.MaxValue || end < start) return (null, FocasStatusMapper.BadOutOfRange); try { var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken) @@ -334,18 +335,38 @@ public sealed class WireFocasClient : IFocasClient // ---- PMC / Parameter / Macro read paths ------------------------------------------ + /// + /// The 16-bit byte range pmc_rdpmcrng addresses for a slot of bytes at + /// . False when any byte of it falls outside 0..65535 — the range is refused + /// rather than wrapped onto a different PMC address (an Unknown-series device admits any number). + /// + /// The authored PMC byte address. + /// The slot width in bytes (≥ 1). + /// The first byte, when representable. + /// The last byte, when representable. + /// True when the whole slot is addressable. + internal static bool TryPmcRange(int number, int width, out ushort start, out ushort end) + { + start = end = 0; + var last = (long)number + Math.Max(width, 1) - 1; + if (number < 0 || last > ushort.MaxValue) return false; + start = (ushort)number; + end = (ushort)last; + return true; + } + private async Task<(object? value, uint status)> ReadPmcAsync( FocasAddress address, FocasDataType type, CancellationToken cancellationToken) { var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty); if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown); var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type); - var start = (ushort)address.Number; // pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs // the range widened to its byte width, else the value parser gets too few bytes → 0 values // → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned // a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25. - var end = (ushort)(start + FocasWireClient.PmcByteWidth((short)dataType) - 1); + if (!TryPmcRange(address.Number, FocasWireClient.PmcByteWidth((short)dataType), out var start, out var end)) + return (null, FocasStatusMapper.BadOutOfRange); try { @@ -373,23 +394,23 @@ public sealed class WireFocasClient : IFocasClient switch (type) { case FocasDataType.Byte: - var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var b = await _wire.ReadParameterByteAsync(address.Number, 0, cancellationToken).ConfigureAwait(false); return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc)); case FocasDataType.Int16: - var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var s = await _wire.ReadParameterInt16Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc)); case FocasDataType.Float32: - var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var f = await _wire.ReadParameterFloat32Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc)); case FocasDataType.Float64: - var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var d = await _wire.ReadParameterFloat64Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc)); case FocasDataType.Bit when address.BitIndex is int bit: - var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var bi = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false); if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc)); return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good); default: - var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false); + var i = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false); return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc)); } } @@ -404,7 +425,7 @@ public sealed class WireFocasClient : IFocasClient { try { - var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false); + var result = await _wire.ReadMacroAsync(address.Number, cancellationToken).ConfigureAwait(false); if (!result.IsOk || result.Value is null) return (null, FocasStatusMapper.MapFocasReturn(result.Rc)); return MapMacro(result.Value, type, address.BitIndex); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs index 314fda86..4cbf460e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs @@ -72,6 +72,75 @@ public sealed class WireBackendCoverageTests } } + /// + /// #740 / R07:H14 — a macro / parameter number above 32767 reaches the CNC unnarrowed. The mock decodes + /// the request's 32-bit Arg1 as the number, so a decoy is seeded at the address the old + /// (short) cast produced (99999 → -31073, 40000 → -25536, as the unsigned key the mock sees): + /// reading the decoy's value means the number was wrapped. #500 / #6711 are the positive control that + /// ordinary numbers still read. + /// + [Fact] + public async Task Macro_and_parameter_numbers_above_short_range_read_the_authored_variable() + { + if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); + var ct = TestContext.Current.CancellationToken; + + static string Wrapped(int number) => ((uint)(int)(short)number).ToString(System.Globalization.CultureInfo.InvariantCulture); + + await _fx.LoadProfileAsync("FWLIB64", ct); + await _fx.PatchStateAsync(new + { + parameters = new Dictionary + { + ["6711"] = new { type = "long", value = 1234, @decimal = 0 }, + ["40000"] = new { type = "long", value = 4242, @decimal = 0 }, + [Wrapped(40000)] = new { type = "long", value = -1, @decimal = 0 }, + }, + macros = new Dictionary + { + ["500"] = new { value = 42000, @decimal = 3 }, + ["99999"] = new { value = 7, @decimal = 0 }, + [Wrapped(99999)] = new { value = 666, @decimal = 0 }, + }, + }, ct); + + var drv = new FocasDriver(new FocasDriverOptions + { + // ThirtyOne_i: the capability matrix admits #99999 (its documented ceiling); the parameter is read on + // an Unknown-series device, which admits any number. + Devices = [new FocasDeviceOptions(DeviceHost, Series: FocasCncSeries.ThirtyOne_i)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false), + new FocasTagDefinition("Macro99999", DeviceHost, "MACRO:99999", FocasDataType.Int32, Writable: false), + new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + }, driverInstanceId: "wire-wide-numbers", clientFactory: new WireFocasClientFactory()); + + var unknownSeries = new FocasDriver(new FocasDriverOptions + { + Devices = [new FocasDeviceOptions(DeviceHost)], + RawTags = FocasRawTags.Entries( + new FocasTagDefinition("Param40000", DeviceHost, "PARAM:40000", FocasDataType.Int32, Writable: false)), + Probe = new FocasProbeOptions { Enabled = false }, + }, driverInstanceId: "wire-wide-param", clientFactory: new WireFocasClientFactory()); + + await using (drv) + await using (unknownSeries) + { + await drv.InitializeAsync("{}", ct); + var snaps = await drv.ReadAsync(["Macro500", "Macro99999", "Param6711"], ct); + snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good); + Convert.ToDouble(snaps[0].Value).ShouldBe(42.0, tolerance: 0.001); + snaps[1].Value.ShouldBe(7, "MACRO:99999 must read #99999, not the wrapped #-31073 decoy (666)"); + Convert.ToInt32(snaps[2].Value).ShouldBe(1234); + + await unknownSeries.InitializeAsync("{}", ct); + var param = await unknownSeries.ReadAsync(["Param40000"], ct); + param[0].StatusCode.ShouldBe(FocasStatusMapper.Good); + Convert.ToInt32(param[0].Value).ShouldBe(4242, "PARAM:40000 must not read the wrapped decoy (-1)"); + } + } + /// Verifies that discover emits device folder and tag variables. [Fact] public async Task Discover_emits_device_folder_and_tag_variables() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs new file mode 100644 index 00000000..7bf63a40 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs @@ -0,0 +1,84 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; + +/// +/// #740 / R07:H14 — the capability matrix admits macro numbers up to 99999 (30i/31i/32i) and parameters up +/// to 29999 (Unknown: anything), and the FOCAS/2 request block carries 32-bit arguments, but the adapter +/// cast every number to short: MACRO:99999 asked the CNC for #-31073 and published +/// that variable Good under the authored tag. Numbers now travel as int end to end. +/// +[Trait("Category", "Unit")] +public sealed class FocasWideNumberTests +{ + /// The macro request carries the full number in both 32-bit argument slots. + [Theory] + [InlineData(500)] + [InlineData(32767)] + [InlineData(32768)] + [InlineData(99999)] + public void Macro_request_carries_the_unnarrowed_number(int number) + { + var block = FocasWireClient.MacroRequest(number, pathId: 1); + block.Command.ShouldBe((ushort)0x0015); + block.Arg1.ShouldBe(number); + block.Arg2.ShouldBe(number); + } + + /// + /// Positive control on the wire encoding: the request for #500 is byte-identical to the one the real + /// 31i-B answered in the ref-macro-500-0x0015 capture, so widening changed nothing below 32768. + /// + [Fact] + public void Macro_request_for_500_matches_the_live_capture_bytes() + { + var body = FocasWireProtocol.BuildRequestBody([FocasWireClient.MacroRequest(500, pathId: 1)]); + Convert.ToHexString(body).ShouldBe("0001001C0001000100150000 01F4000001F4000000000000000000000000".Replace(" ", "")); + } + + /// A global parameter puts its number in both arguments; an axis parameter puts the axis second. + [Theory] + [InlineData(6711, (short)0, 6711)] + [InlineData(40000, (short)0, 40000)] + [InlineData(40000, (short)2, 2)] + public void Parameter_request_carries_the_unnarrowed_number(int number, short axis, int expectedArg2) + { + var block = FocasWireClient.ParameterRequest(number, axis, pathId: 1); + block.Command.ShouldBe((ushort)0x000e); + block.Arg1.ShouldBe(number); + block.Arg2.ShouldBe(expectedArg2); + } + + /// The decoded macro reports the number that was asked for, not a wrapped one. + [Fact] + public void Parsed_macro_keeps_a_number_above_short_range() + { + var payload = new byte[] { 0x05, 0xF5, 0xE1, 0x00, 0x00, 0x0A, 0x00, 0x08 }; + var m = FocasWireClient.ParseMacro(99999, payload); + m.Number.ShouldBe(99999); + m.Scaled.ShouldBe(1.0); + } + + /// + /// pmc_rdpmcrng addresses are 16-bit on the wire; a PMC number whose slot runs past 65535 is refused + /// rather than wrapped onto a different address (Unknown series admits any number). + /// + [Theory] + [InlineData(100, 1, true)] + [InlineData(65535, 1, true)] + [InlineData(65534, 2, true)] + [InlineData(65535, 2, false)] + [InlineData(70000, 1, false)] + public void Pmc_range_outside_16_bits_is_refused_not_wrapped(int number, int width, bool ok) + { + WireFocasClient.TryPmcRange(number, width, out var start, out var end).ShouldBe(ok); + if (ok) + { + ((int)start).ShouldBe(number); + ((int)end).ShouldBe(number + width - 1); + } + } +}