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