Merge b640-focas: FOCAS Status/ subtree, PMC range coalescing, parts-count docs (#257 #266 #258, tracker #633)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:03:53 -04:00
14 changed files with 1324 additions and 16 deletions
@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// Live-wire coverage (focas-mock) for #257 (<c>cnc_statinfo</c> status flags) and #266 (PMC
/// range coalescing). Skips cleanly when <c>OTOPCUA_FOCAS_SIM_ENDPOINT</c> (default
/// <c>localhost:8193</c>) is unreachable. Read-only: no write path is exercised.
/// </summary>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendStatusAndPmcTests
{
private readonly FocasSimFixture _fx;
/// <summary>Initializes a new instance of the <see cref="WireBackendStatusAndPmcTests"/> class.</summary>
/// <param name="fx">The FOCAS simulator fixture.</param>
public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx;
private string DeviceHost => _fx.DeviceUri;
/// <summary>The mock's <c>cnc_statinfo</c> flags surface through the Status/ subtree and an authored FIXED: tag.</summary>
[Fact]
public async Task Status_flags_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
sysinfo = new
{
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
series = "30i ", version = "A1.0", axes = "1 ",
},
axis_names = new[] { "X" },
statinfo = new { aut = 1, run = 3, motion = 1, mstb = 0, emergency = 0, alarm = 1, edit = 0 },
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("RunFlag", DeviceHost, "FIXED:Status/Run", FocasDataType.Int32, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
},
}, driverInstanceId: "focas-wire-status", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus: not null }, TimeSpan.FromSeconds(5));
var state = driver.GetDeviceState(DeviceHost);
state.ShouldNotBeNull();
state.LastStatus.ShouldNotBeNull();
state.LastStatus.Run.ShouldBe(3);
state.LastStatus.Alarm.ShouldBe(1);
var snaps = await driver.ReadAsync(["RunFlag", $"{DeviceHost}/Status/Alarm", $"{DeviceHost}/Status/RunText"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
snaps[0].Value.ShouldBe(3);
snaps[1].Value.ShouldBe(1);
snaps[2].Value.ShouldBe("START");
// The flags follow the machine.
await _fx.PatchStateAsync(new { statinfo = new { run = 1, emergency = 1 } }, ct);
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus.Run: 1 }, TimeSpan.FromSeconds(5));
driver.GetDeviceState(DeviceHost)!.LastStatus!.Emergency.ShouldBe(1);
}
}
/// <summary>
/// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets
/// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are
/// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real
/// byte-addressed range only for width 1.
/// </summary>
[Fact]
public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
pmc = new { R = new Dictionary<string, object>
{
["200"] = new { type = "byte", value = 17 },
["201"] = new { type = "byte", value = 200 },
["202"] = new { type = "byte", value = 0b0000_0100 },
["203"] = new { type = "byte", value = 9 },
["900"] = new { type = "byte", value = 33 },
}},
}, ct);
var counting = new CountingFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false),
new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting);
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snaps[0].Value).ShouldBe(17);
Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte
snaps[2].Value.ShouldBe(true);
Convert.ToInt32(snaps[3].Value).ShouldBe(9);
Convert.ToInt32(snaps[4].Value).ShouldBe(33);
counting.Client.ShouldNotBeNull();
counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng");
counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own");
}
}
private sealed class CountingFactory : IFocasClientFactory
{
private readonly WireFocasClientFactory _inner = new();
public CountingClient? Client { get; private set; }
public void EnsureUsable() => _inner.EnsureUsable();
public IFocasClient Create() => Client = new CountingClient(_inner.Create());
}
private sealed class CountingClient(IFocasClient inner) : IFocasClient
{
public int RangeReads { get; private set; }
public int SingleReads { get; private set; }
public bool IsConnected => inner.IsConnected;
public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct);
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct)
{ SingleReads++; return inner.ReadAsync(a, t, ct); }
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct)
{ RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); }
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct);
public Task<bool> ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct);
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct);
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct);
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct);
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct);
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct);
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct);
public Task<FocasStatus> GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct);
public Task<FocasTimer> GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct);
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct);
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct);
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct);
public void Dispose() => inner.Dispose();
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(50);
}
}
}
@@ -56,6 +56,51 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult((value, status));
}
/// <summary>PMC byte memory the range read serves, keyed by (upper-case letter, byte address); absent = 0.</summary>
public Dictionary<(string Letter, int Address), byte> PmcMemory { get; } = new();
/// <summary>Gets the log of range reads issued (letter, slot type, start, end).</summary>
public List<(string Letter, FocasDataType SlotType, int Start, int End)> PmcRangeReads { get; } = new();
/// <summary>Ranges (letter, start, end) the fake CNC rejects with <see cref="PmcRangeRejectStatus"/>.</summary>
public List<Func<string, int, int, bool>> PmcRangeRejectors { get; } = new();
/// <summary>The status a rejected range returns.</summary>
public uint PmcRangeRejectStatus { get; set; } = FocasStatusMapper.BadOutOfRange;
/// <summary>When true every range read throws (transport failure).</summary>
public bool ThrowOnPmcRange { get; set; }
/// <summary>Reads a PMC range as raw big-endian slots from <see cref="PmcMemory"/>.</summary>
/// <param name="letter">PMC letter.</param>
/// <param name="slotType">Slot type.</param>
/// <param name="start">First byte.</param>
/// <param name="end">Last byte, inclusive.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken ct)
{
PmcRangeReads.Add((letter, slotType, start, end));
if (ThrowOnPmcRange) throw new InvalidOperationException("transport down");
if (PmcRangeRejectors.Any(r => r(letter, start, end)))
return Task.FromResult<(IReadOnlyList<long>?, uint)>((null, PmcRangeRejectStatus));
var width = slotType switch
{
FocasDataType.Int16 => 2,
FocasDataType.Int32 or FocasDataType.Float32 => 4,
FocasDataType.Float64 => 8,
_ => 1,
};
var slots = new List<long>();
for (var o = start; o + width - 1 <= end; o += width)
{
long v = 0;
for (var k = 0; k < width; k++)
v = (v << 8) | (PmcMemory.TryGetValue((letter, o + k), out var b) ? b : (byte)0);
// Sign-extend like the wire parser (Int16/Int32/Int64 big-endian); width 1 stays 0..255.
v = width switch { 2 => (short)v, 4 => (int)v, _ => v };
slots.Add(v);
}
return Task.FromResult<(IReadOnlyList<long>?, uint)>((slots, FocasStatusMapper.Good));
}
/// <summary>Writes a value to a FOCAS address asynchronously.</summary>
/// <param name="address">The FOCAS address to write to.</param>
/// <param name="type">The data type of the value.</param>
@@ -121,6 +166,21 @@ internal class FakeFocasClient : IFocasClient
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
Task.FromResult(ProgramInfo);
/// <summary>Gets or sets the cnc_statinfo status snapshot.</summary>
public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0);
/// <summary>Gets or sets a value indicating whether <see cref="GetStatusAsync"/> throws (older series / refused call).</summary>
public bool ThrowOnStatus { get; set; }
/// <summary>Gets the number of status reads served.</summary>
public int StatusReads { get; private set; }
/// <summary>Gets the CNC status flags asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasStatus> GetStatusAsync(CancellationToken ct)
{
StatusReads++;
if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused");
return Task.FromResult(Status);
}
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
/// <summary>Gets timer data asynchronously.</summary>
@@ -0,0 +1,111 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>Gitea #266 — the pure planner behind PMC range coalescing.</summary>
[Trait("Category", "Unit")]
public sealed class FocasPmcCoalescerTests
{
private static FocasPmcReadItem B(int index, string letter, int number, FocasDataType t = FocasDataType.Byte, int? bit = null) =>
new(index, letter, number, t, bit);
[Fact]
public void Adjacent_bytes_merge_into_one_range()
{
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101), B(2, "R", 102)], 128, 8);
var p = plans.ShouldHaveSingleItem();
(p.Letter, p.Start, p.End).ShouldBe(("R", 100, 102));
p.Members.Count.ShouldBe(3);
}
[Fact]
public void A_gap_within_the_threshold_merges_and_beyond_it_splits()
{
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 108)], 128, 8).Count.ShouldBe(1); // 7 byte gap
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 110)], 128, 8).Count.ShouldBe(2); // 9 byte gap
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 102)], 128, 0).Count.ShouldBe(2); // gap budget 0
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101)], 128, 0).Count.ShouldBe(1); // adjacent still fine
}
[Fact]
public void The_span_cap_is_a_hard_limit_on_range_length()
{
var items = Enumerable.Range(0, 10).Select(i => B(i, "R", 100 + i)).ToList();
var plans = FocasPmcCoalescer.Plan(items, maxSpanBytes: 4, maxGapBytes: 8);
plans.Select(p => p.End - p.Start + 1).ShouldAllBe(len => len <= 4);
plans.Sum(p => p.Members.Count).ShouldBe(10);
plans.Count.ShouldBe(3); // 4 + 4 + 2
}
[Fact]
public void Different_letters_never_merge()
{
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "D", 101), B(2, "R", 101)], 128, 8);
plans.Count.ShouldBe(2);
plans.Single(p => p.Letter == "R").Members.Count.ShouldBe(2);
plans.Single(p => p.Letter == "D").Members.Count.ShouldBe(1);
}
[Fact]
public void Bits_of_one_byte_share_a_slot_and_bits_and_bytes_share_a_range()
{
var plans = FocasPmcCoalescer.Plan(
[B(0, "X", 0, FocasDataType.Bit, 0), B(1, "X", 0, FocasDataType.Bit, 7), B(2, "X", 1)], 128, 8);
var p = plans.ShouldHaveSingleItem();
(p.Start, p.End).ShouldBe((0, 1));
p.SlotOf(p.Members.Single(m => m.Index == 2)).ShouldBe(1);
}
[Fact]
public void Words_merge_only_when_aligned_to_the_range_start_and_the_range_is_whole_slots()
{
// 100,102 aligned -> one range [100,103]; 105 is unaligned (offset 5) -> its own range.
var plans = FocasPmcCoalescer.Plan(
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 102, FocasDataType.Int16), B(2, "D", 105, FocasDataType.Int16)], 128, 8);
plans.Count.ShouldBe(2);
var first = plans.Single(p => p.Start == 100);
(first.End, first.SlotWidth, first.Members.Count).ShouldBe((103, 2, 2));
first.SlotOf(first.Members.Single(m => m.Number == 102)).ShouldBe(1);
plans.Single(p => p.Start == 105).Members.Count.ShouldBe(1);
}
[Fact]
public void A_word_range_with_a_gap_is_rounded_up_to_whole_slots()
{
// 100 (word) + 103 (word): offset 3 is unaligned -> split; 100 + 104 aligned, gap 2 -> merged, end 105.
var plans = FocasPmcCoalescer.Plan(
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 104, FocasDataType.Int16)], 128, 8);
var p = plans.ShouldHaveSingleItem();
(p.Start, p.End).ShouldBe((100, 105));
((p.End - p.Start + 1) % p.SlotWidth).ShouldBe(0);
}
[Fact]
public void Different_widths_on_one_letter_do_not_merge()
{
FocasPmcCoalescer.Plan([B(0, "D", 100, FocasDataType.Byte), B(1, "D", 101, FocasDataType.Int16)], 128, 8)
.Count.ShouldBe(2);
}
[Fact]
public void Every_input_appears_in_exactly_one_plan_and_input_order_is_irrelevant()
{
var items = new[] { B(0, "R", 300), B(1, "R", 100), B(2, "R", 102), B(3, "R", 101), B(4, "R", 104), B(5, "R", 300) };
var a = FocasPmcCoalescer.Plan(items, 128, 8);
var b = FocasPmcCoalescer.Plan(items.Reverse(), 128, 8);
a.SelectMany(p => p.Members).Select(m => m.Index).OrderBy(x => x).ShouldBe([0, 1, 2, 3, 4, 5]);
a.Select(p => (p.Start, p.End)).ShouldBe(b.Select(p => (p.Start, p.End)));
}
[Fact]
public void Slot_decode_matches_the_per_tag_mapping()
{
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 2).ShouldBe(true);
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 3).ShouldBe(false);
FocasPmcCoalescer.MapSlot(0xFF, FocasDataType.Byte, null).ShouldBe((sbyte)-1);
FocasPmcCoalescer.MapSlot(-2, FocasDataType.Int16, null).ShouldBe((short)-2);
FocasPmcCoalescer.MapSlot(BitConverter.SingleToInt32Bits(1.5f), FocasDataType.Float32, null).ShouldBe(1.5f);
}
}
@@ -0,0 +1,243 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>Gitea #266 — driver-level behaviour of PMC range coalescing in a poll batch.</summary>
[Trait("Category", "Unit")]
public sealed class FocasPmcCoalescingDriverTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasTagDefinition Tag(string name, string addr, FocasDataType t) =>
new(name, Host, addr, t, Writable: false);
private static async Task<(FocasDriver drv, FakeFocasClient client)> StartAsync(
FocasPmcCoalesceOptions? opts, params FocasTagDefinition[] tags)
{
var client = new FakeFocasClient();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(tags),
PmcCoalesce = opts ?? new FocasPmcCoalesceOptions(),
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, client);
}
[Fact]
public async Task Adjacent_pmc_tags_are_served_by_one_range_read_and_sliced_back_per_tag()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte),
Tag("B", "R101", FocasDataType.Byte),
Tag("Bit", "R102.3", FocasDataType.Bit),
Tag("Far", "R900", FocasDataType.Byte));
c.PmcMemory[("R", 100)] = 0x11;
c.PmcMemory[("R", 101)] = 0xFF;
c.PmcMemory[("R", 102)] = 0b0000_1000;
c.Values["R900"] = (sbyte)7;
var r = await drv.ReadAsync(["A", "B", "Bit", "Far"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 102)]);
r[0].Value.ShouldBe((sbyte)0x11);
r[1].Value.ShouldBe((sbyte)-1);
r[2].Value.ShouldBe(true);
r[3].Value.ShouldBe((sbyte)7); // lone tag: ordinary per-tag path, no range read of its own
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Aligned_words_decode_from_their_own_slot()
{
var (drv, c) = await StartAsync(null,
Tag("W0", "D200", FocasDataType.Int16),
Tag("W1", "D202", FocasDataType.Int16));
c.PmcMemory[("D", 200)] = 0x01; c.PmcMemory[("D", 201)] = 0x02; // 0x0102 = 258
c.PmcMemory[("D", 202)] = 0xFF; c.PmcMemory[("D", 203)] = 0xFE; // -2
var r = await drv.ReadAsync(["W0", "W1"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("D", FocasDataType.Int16, 200, 203)]);
r[0].Value.ShouldBe((short)258);
r[1].Value.ShouldBe((short)-2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_single_pmc_tag_never_issues_a_range_read()
{
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte));
c.Values["R100"] = (sbyte)5;
var r = await drv.ReadAsync(["A"], CancellationToken.None);
c.PmcRangeReads.ShouldBeEmpty();
r[0].Value.ShouldBe((sbyte)5);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Disabling_coalescing_restores_per_tag_reads()
{
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { Enabled = false },
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.ShouldBeEmpty();
r[0].Value.ShouldBe((sbyte)1);
r[1].Value.ShouldBe((sbyte)2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_span_cap_and_gap_threshold_from_options_are_honoured()
{
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { MaxSpanBytes = 2, MaxGapBytes = 0 },
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte), Tag("C", "R102", FocasDataType.Byte));
c.Values["R102"] = (sbyte)9;
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 101)]); // C is alone -> per-tag
r[2].Value.ShouldBe((sbyte)9);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_range_the_cnc_rejects_falls_back_to_per_tag_reads_with_their_own_status_and_is_remembered()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R106", FocasDataType.Byte));
c.PmcRangeRejectors.Add((_, s, e) => e - s + 1 > 2); // the spare gap bytes are not valid addresses
c.Values["R100"] = (sbyte)1;
c.Values["R106"] = (sbyte)2;
c.ReadStatuses["R106"] = FocasStatusMapper.BadNodeIdUnknown; // B is genuinely bad; A must NOT inherit that
var r1 = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r1[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
r1[0].Value.ShouldBe((sbyte)1);
r1[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(1, "a rejected range is remembered; later polls go straight to per-tag reads");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_refusal_with_an_unmapped_rc_that_maps_to_BadCommunicationError_still_falls_back_per_tag()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.PmcRangeRejectors.Add((_, _, _) => true);
c.PmcRangeRejectStatus = FocasStatusMapper.BadCommunicationError; // what an unlisted rc / EW_PARITY / EW_SOCKET maps to
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe([(object)(sbyte)1, (sbyte)2]);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(1, "a deterministic rc refusal is memoised");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_transient_busy_refusal_is_not_memoised()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
var busy = true;
c.PmcRangeRejectors.Add((_, _, _) => busy);
c.PmcRangeRejectStatus = FocasStatusMapper.BadDeviceFailure; // EW_BUSY
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
(await drv.ReadAsync(["A", "B"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
busy = false;
c.PmcMemory[("R", 100)] = 5; c.PmcMemory[("R", 101)] = 6;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(2, "the range is retried once the CNC is no longer busy");
r.Select(s => s.Value).ShouldBe([(object)(sbyte)5, (sbyte)6]);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_down_device_costs_one_connect_attempt_per_batch_not_one_per_plan_and_tag()
{
var client = new FakeFocasClient { ThrowOnConnect = true };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(new[]
{
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte),
Tag("C", "R900", FocasDataType.Byte), Tag("D", "R2000", FocasDataType.Byte),
}),
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var before = client.ConnectCount;
var r = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
(client.ConnectCount - before).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_transport_failure_fails_every_member_without_per_tag_retries()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.ThrowOnPmcRange = true;
c.Values["R100"] = (sbyte)1; // would read Good if the driver wrongly fell back
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
r.ShouldAllBe(s => s.Value == null);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Result_order_follows_the_request_when_pmc_and_non_pmc_tags_are_mixed()
{
var (drv, c) = await StartAsync(null,
Tag("P", "PARAM:6711", FocasDataType.Int32),
Tag("A", "R100", FocasDataType.Byte),
Tag("B", "R101", FocasDataType.Byte));
c.Values["PARAM:6711"] = 1234;
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
var r = await drv.ReadAsync(["B", "P", "A"], CancellationToken.None);
r[0].Value.ShouldBe((sbyte)4);
r[1].Value.ShouldBe(1234);
r[2].Value.ShouldBe((sbyte)3);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Duplicate_references_in_one_batch_each_get_their_value()
{
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
var r = await drv.ReadAsync(["A", "B", "A"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe([(object)(sbyte)3, (sbyte)4, (sbyte)3]);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #257 — <c>cnc_statinfo</c> (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode)
/// surfaces as a read-only <c>Status/</c> fixed-tree subtree, authorable as <c>FIXED:Status/Run</c>.
/// The wire client already decoded the call (<c>FocasWireClient.ReadStatusAsync</c>) but nothing consumed it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasStatusFlagsTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(FakeFocasClient client, params FocasTagDefinition[] tags) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(tags),
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(10),
ProgramPollInterval = TimeSpan.FromMilliseconds(10),
},
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task WaitAsync(Func<bool> predicate)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && !predicate()) await Task.Delay(15);
predicate().ShouldBeTrue("condition never became true");
}
[Fact]
public void The_wire_status_block_maps_field_for_field()
{
var s = Wire.WireFocasClient.MapStatus(new Wire.WireStatus(Auto: 1, Run: 2, Motion: 3, Mstb: 4, Emergency: 5, Alarm: 6, Edit: 7, TmMode: 8));
s.ShouldBe(new FocasStatus(1, 2, 3, 4, 5, 6, 7, 8));
}
[Theory]
[InlineData(0, "RESET")]
[InlineData(1, "STOP")]
[InlineData(2, "HOLD")]
[InlineData(3, "START")]
[InlineData(4, "MSTR")]
[InlineData(9, "Run9")]
public void Run_state_text(int run, string text) => FocasRunState.ToText(run).ShouldBe(text);
[Fact]
public async Task Status_flags_read_good_through_the_fixed_tree_references()
{
var client = new FakeFocasClient { Status = new FocasStatus(Auto: 1, Run: 3, Motion: 1, Mstb: 2, Emergency: 0, Alarm: 1, Edit: 0, TmMode: 4) };
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
string[] fields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
int[] expected = [1, 3, 1, 2, 0, 1, 0, 4];
var read = await drv.ReadAsync([.. fields.Select(f => $"{Host}/Status/{f}")], CancellationToken.None);
for (var i = 0; i < fields.Length; i++)
{
read[i].StatusCode.ShouldBe(FocasStatusMapper.Good, fields[i]);
read[i].Value.ShouldBe(expected[i], fields[i]);
}
var text = await drv.ReadAsync([$"{Host}/Status/RunText"], CancellationToken.None);
text[0].Value.ShouldBe("START");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task An_authored_FIXED_Status_tag_reads_the_flag()
{
var client = new FakeFocasClient { Status = new FocasStatus(1, 3, 1, 0, 1, 0, 0, 0) };
var drv = NewDriver(client,
new FocasTagDefinition("EStop", Host, "FIXED:Status/Emergency", FocasDataType.Int32, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
var snap = (await drv.ReadAsync(["EStop"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(FocasStatusMapper.Good);
snap.Value.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Status_updates_follow_the_machine()
{
var client = new FakeFocasClient { Status = new FocasStatus(1, 0, 0, 0, 0, 0, 0, 0) };
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
client.Status = new FocasStatus(1, 3, 1, 0, 0, 0, 0, 0);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus?.Run == 3);
(await drv.ReadAsync([$"{Host}/Status/Run"], CancellationToken.None))[0].Value.ShouldBe(3);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_cnc_that_refuses_statinfo_gets_no_Status_node_and_the_rest_of_the_tree_survives()
{
var client = new FakeFocasClient { ThrowOnStatus = true };
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldNotContain(f => f.BrowseName == "Status");
builder.Variables.ShouldContain(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Discovery_emits_every_status_leaf_with_a_parseable_read_only_address()
{
var drv = NewDriver(new FakeFocasClient());
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Status");
var leaves = builder.Variables.Where(v => v.Info.FullName.StartsWith("FIXED:Status/", StringComparison.Ordinal)).ToList();
leaves.Select(l => l.BrowseName).ShouldBe(
["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode", "RunText"], ignoreOrder: true);
foreach (var (_, info) in leaves)
{
FocasAddress.TryParse(info.FullName).ShouldNotBeNull();
info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string name, DriverDataType type, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}