fix(focas): carry macro/parameter numbers to the CNC unnarrowed (R07:H14)

The capability matrix admits #0..99999 on 30i/31i/32i (and anything on Unknown), and
the FOCAS/2 request block has always carried 32-bit arguments, but WireFocasClient cast
every number to short: MACRO:99999 asked the CNC for #-31073 and published that variable
at Good under the authored tag. FocasWireClient's macro/parameter reads, WireMacro.Number
and WireParameter.DataNumber are now int; the request blocks come from MacroRequest /
ParameterRequest builders (byte-identical to the live 31i-B capture for #500).

Same class on PMC: a range whose last byte passes 65535 (Unknown series admits it) is now
refused BadOutOfRange instead of wrapping onto a different 16-bit address.

The wire client is in-process (the other end is the CNC itself), so this is not a
cross-repo contract change. Whether a real 30i honours cnc_rdmacro Arg1 > 32767 is the
hardware userGate.

Refs #740
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:07:42 -04:00
parent a32894e93b
commit 12b981dc1b
5 changed files with 225 additions and 25 deletions
@@ -467,18 +467,41 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The raw parameter payload bytes, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<byte[]>> 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);
}
/// <summary>
/// The <c>cnc_rdparam</c> (0x000e) request block. The data number travels in the 32-bit
/// <c>Arg1</c> UNNARROWED — the request block has always carried <see cref="int"/> arguments, and
/// a <c>short</c> cast in the adapter once asked the CNC for a different (wrapped) number than the
/// one authored. A global parameter repeats the number in <c>Arg2</c>; an axis parameter carries
/// the axis there.
/// </summary>
/// <param name="dataNumber">The parameter data number.</param>
/// <param name="axis">Axis selector; zero means a global parameter.</param>
/// <param name="pathId">The CNC path.</param>
/// <returns>The request block.</returns>
internal static RequestBlock ParameterRequest(int dataNumber, short axis, ushort pathId) =>
new(0x000e, dataNumber, axis == 0 ? dataNumber : axis, PathId: pathId);
/// <summary>
/// The <c>cnc_rdmacro</c> (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.
/// </summary>
/// <param name="number">The macro variable number.</param>
/// <param name="pathId">The CNC path.</param>
/// <returns>The request block.</returns>
internal static RequestBlock MacroRequest(int number, ushort pathId) =>
new(0x0015, number, number, PathId: pathId);
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
/// <param name="dataNumber">The parameter data number.</param>
/// <param name="type">The FOCAS parameter type code.</param>
@@ -487,7 +510,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<WireParameter>> ReadParameterAsync(
short dataNumber,
int dataNumber,
short type = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
@@ -507,7 +530,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 8-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<byte>> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<byte>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 16-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<short>> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<short>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed 32-bit parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<int>> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<int>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed single-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<float>> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<float>> 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
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The typed double-precision parameter value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<double>> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
public async Task<FocasResult<double>> 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
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The macro variable value, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<WireMacro>> 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);
}
/// <summary>
/// Decode a <c>cnc_rdmacro</c> (0x0015) payload. The on-wire <c>ODBM</c> record is 8
@@ -609,7 +635,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// <param name="payload">The response-block payload.</param>
/// <returns>The decoded macro.</returns>
/// <exception cref="FocasWireException">The payload is shorter than 8 bytes.</exception>
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));
@@ -170,7 +170,7 @@ public readonly record struct WireSpindleRecord(short Index, string Name);
/// <summary>Parameter value returned by <c>cnc_rdparam</c>, interpreted as a scalar Int32.</summary>
public sealed record WireParameter(
short DataNumber,
int DataNumber,
short Type,
int Value);
@@ -184,7 +184,7 @@ public sealed record WireParameter(
/// <param name="Value">The 9-significant-digit mantissa.</param>
/// <param name="Decimal">Implied decimal places; negative (<c>0xFFFF</c> on the wire) = vacant.</param>
public sealed record WireMacro(
short Number,
int Number,
int Value,
short Decimal)
{
@@ -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 ------------------------------------------
/// <summary>
/// The 16-bit byte range <c>pmc_rdpmcrng</c> addresses for a slot of <paramref name="width"/> bytes at
/// <paramref name="number"/>. 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).
/// </summary>
/// <param name="number">The authored PMC byte address.</param>
/// <param name="width">The slot width in bytes (≥ 1).</param>
/// <param name="start">The first byte, when representable.</param>
/// <param name="end">The last byte, when representable.</param>
/// <returns>True when the whole slot is addressable.</returns>
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);
@@ -72,6 +72,75 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>
/// #740 / R07:H14 — a macro / parameter number above 32767 reaches the CNC unnarrowed. The mock decodes
/// the request's 32-bit <c>Arg1</c> as the number, so a decoy is seeded at the address the old
/// <c>(short)</c> 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.
/// </summary>
[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<string, object>
{
["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<string, object>
{
["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)");
}
}
/// <summary>Verifies that discover emits device folder and tag variables.</summary>
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
@@ -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;
/// <summary>
/// #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 <c>short</c>: <c>MACRO:99999</c> asked the CNC for <c>#-31073</c> and published
/// that variable Good under the authored tag. Numbers now travel as <c>int</c> end to end.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasWideNumberTests
{
/// <summary>The macro request carries the full number in both 32-bit argument slots.</summary>
[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);
}
/// <summary>
/// Positive control on the wire encoding: the request for #500 is byte-identical to the one the real
/// 31i-B answered in the <c>ref-macro-500-0x0015</c> capture, so widening changed nothing below 32768.
/// </summary>
[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(" ", ""));
}
/// <summary>A global parameter puts its number in both arguments; an axis parameter puts the axis second.</summary>
[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);
}
/// <summary>The decoded macro reports the number that was asked for, not a wrapped one.</summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
}
}