diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 7152be71..0c0be6f7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -751,6 +751,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String); } + // Fixed-tree T2.5 — Status subtree (cnc_statinfo flags, #257), gated on its own probe. + if (cache.Capabilities.Status) + { + var status = deviceFolder.Folder("Status", "Status"); + foreach (var field in StatusFields) + EmitFixedVariable(status, "Status", field, DriverDataType.Int32); + EmitFixedVariable(status, "Status", "RunText", DriverDataType.String); + } + // Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle). if (cache.Capabilities.Timers) { @@ -1008,6 +1017,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []); var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct)); var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct)); + var statusProbe = await SafeTryProbe(() => client.GetStatusAsync(ct)); var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []); var caps = new FocasFixedTreeCapabilities( @@ -1016,7 +1026,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, SpindleMotorSpeed: spindleMotorSpeeds.Count > 0, ServoLoad: servoLoads.Count > 0, ProgramInfo: programInfo is not null, - Timers: timer is not null); + Timers: timer is not null, + Status: statusProbe is not null); + state.LastStatus = statusProbe; state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps); // The bootstrap IS an answer from the device, and Identity comes entirely from it — @@ -1054,6 +1066,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, var programPollDue = DateTime.MinValue; var timerPollDue = DateTime.MinValue; + var statusPollDue = DateTime.MinValue; while (!ct.IsCancellationRequested) { try @@ -1129,6 +1142,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, programPollDue = DateTime.UtcNow + programInterval; } + // Status flags (cnc_statinfo, #257) — same cadence as program/mode: run/emergency/alarm + // transitions are operator-timescale events and one call returns all of them. + if (cache.Capabilities.Status + && programInterval > TimeSpan.Zero && DateTime.UtcNow >= statusPollDue) + { + try { state.LastStatus = await client.GetStatusAsync(ct).ConfigureAwait(false); } + catch (Exception ex) + { + /* transient — keep the last value; staleness is dated by LastFixedPollUtc */ + _logger.LogDebug(ex, "FOCAS status poll failed for {Host}", state.Options.HostAddress); + } + statusPollDue = DateTime.UtcNow + programInterval; + } + // Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind). var timerInterval = _options.FixedTree.TimerPollInterval; if (cache.Capabilities.Timers @@ -1245,6 +1272,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } + private static readonly string[] StatusFields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"]; + private static object? TimerValue(DeviceState state, FocasTimerKind kind) => state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null; @@ -1385,6 +1414,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, "OperationMode/Mode" => state.LastProgramInfo?.Mode, "OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode ? FocasOpMode.ToText(mode) : null, + "Status/Auto" => state.LastStatus?.Auto, + "Status/Run" => state.LastStatus?.Run, + "Status/Motion" => state.LastStatus?.Motion, + "Status/Mstb" => state.LastStatus?.Mstb, + "Status/Emergency" => state.LastStatus?.Emergency, + "Status/Alarm" => state.LastStatus?.Alarm, + "Status/Edit" => state.LastStatus?.Edit, + "Status/TmMode" => state.LastStatus?.TmMode, + "Status/RunText" => state.LastStatus is { } st ? FocasRunState.ToText(st.Run) : null, "Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn), "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating), "Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting), @@ -1618,7 +1656,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work - bool Timers); // cnc_rdtimer works for at least PowerOn + bool Timers, // cnc_rdtimer works for at least PowerOn + bool Status = false); // cnc_statinfo answered at bootstrap (#257) internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options) { @@ -1683,6 +1722,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// holding no value. /// public ConcurrentDictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase); + /// Gets or sets the last cnc_statinfo snapshot (#257); null until the first answer. + public FocasStatus? LastStatus { get; set; } /// Gets or sets the last program information snapshot. public FocasProgramInfo? LastProgramInfo { get; set; } /// Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs index 2d5d8725..ee339d7e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs @@ -125,6 +125,17 @@ public interface IFocasClient : IDisposable /// The aggregate program + operation-mode snapshot. Task GetProgramInfoAsync(CancellationToken cancellationToken); + // ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ---- + + /// + /// Read the CNC status flags via cnc_statinfo (ODBST). Populates the + /// Status/* subtree. Throws when the CNC refuses the call, which the driver's + /// bootstrap probe reads as "this series has no status block" and suppresses the subtree. + /// + /// The cancellation token. + /// The CNC status flag snapshot. + Task GetStatusAsync(CancellationToken cancellationToken); + // ---- Fixed-tree T3 (timers) ---- /// @@ -172,6 +183,38 @@ public interface IFocasClient : IDisposable Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken); } +/// +/// CNC status flags from cnc_statinfo (ODBST) — raw FANUC codes, published verbatim as +/// Int32 so a client sees exactly what the control reported. additionally has a +/// text projection (). +/// +/// Automatic-operation mode code. +/// Automatic-operation state (0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR). +/// Axis motion (0 none, 1 motion, 2 dwell). +/// M/S/T/B function state (0 none, 1 executing). +/// Emergency stop (0 not, 1 emergency, 2 reset). +/// Alarm state (0 none, 1 alarm, 2 battery-low, 3 fan). +/// Program-edit state. +/// T/M mode as reported by the control. +public sealed record FocasStatus(int Auto, int Run, int Motion, int Mstb, int Emergency, int Alarm, int Edit, int TmMode); + +/// Human-readable text for . +public static class FocasRunState +{ + /// Converts the ODBST.run code to its operator-facing label. + /// The run-state code. + /// The label, or Run{n} for a code outside the documented set. + public static string ToText(int run) => run switch + { + 0 => "RESET", + 1 => "STOP", + 2 => "HOLD", + 3 => "START", + 4 => "MSTR", + _ => $"Run{run}", + }; +} + /// /// One servo-meter entry — one axis's current load percentage; null when the CNC's record /// described no usable value (never a fabricated 0). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs index e2a3f759..db53e176 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs @@ -90,6 +90,10 @@ public sealed class SynchronizedFocasClient : IFocasClient public Task GetProgramInfoAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken); + /// + public Task GetStatusAsync(CancellationToken cancellationToken) => + RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken); + /// public Task GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken); 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 acc104c1..85296fcf 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 @@ -129,6 +129,18 @@ public sealed class WireFocasClient : IFocasClient AxesCount: axesCount); } + /// + public async Task GetStatusAsync(CancellationToken cancellationToken) + { + RequireConnected(); + var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false); + ThrowIfRcNonZero(result.Rc, "cnc_statinfo", result.IsOk); + return MapStatus(result.Value!); + } + + internal static FocasStatus MapStatus(WireStatus s) => + new(s.Auto, s.Run, s.Motion, s.Mstb, s.Emergency, s.Alarm, s.Edit, s.TmMode); + /// public async Task> GetAxisNamesAsync(CancellationToken cancellationToken) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs new file mode 100644 index 00000000..80c6ed69 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendStatusAndPmcTests.cs @@ -0,0 +1,89 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; + +namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series; + +/// +/// Live-wire coverage (focas-mock) for #257 (cnc_statinfo status flags) and #266 (PMC +/// range coalescing). Skips cleanly when OTOPCUA_FOCAS_SIM_ENDPOINT (default +/// localhost:8193) is unreachable. Read-only: no write path is exercised. +/// +[Collection(FocasSimCollection.Name)] +public sealed class WireBackendStatusAndPmcTests +{ + private readonly FocasSimFixture _fx; + + /// Initializes a new instance of the class. + /// The FOCAS simulator fixture. + public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx; + + private string DeviceHost => _fx.DeviceUri; + + /// The mock's cnc_statinfo flags surface through the Status/ subtree and an authored FIXED: tag. + [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); + } + } + + private static async Task WaitFor(Func pred, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (pred()) return; + await Task.Delay(50); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs index ce8671e6..d3197a69 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs @@ -121,6 +121,21 @@ internal class FakeFocasClient : IFocasClient public virtual Task GetProgramInfoAsync(CancellationToken ct) => Task.FromResult(ProgramInfo); + /// Gets or sets the cnc_statinfo status snapshot. + public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0); + /// Gets or sets a value indicating whether throws (older series / refused call). + public bool ThrowOnStatus { get; set; } + /// Gets the number of status reads served. + public int StatusReads { get; private set; } + /// Gets the CNC status flags asynchronously. + /// The cancellation token. + public virtual Task GetStatusAsync(CancellationToken ct) + { + StatusReads++; + if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused"); + return Task.FromResult(Status); + } + /// Gets the dictionary of timers keyed by timer kind. public Dictionary Timers { get; } = []; /// Gets timer data asynchronously. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFlagsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFlagsTests.cs new file mode 100644 index 00000000..8ee12585 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStatusFlagsTests.cs @@ -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; + +/// +/// Gitea #257 — cnc_statinfo (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode) +/// surfaces as a read-only Status/ fixed-tree subtree, authorable as FIXED:Status/Run. +/// The wire client already decoded the call (FocasWireClient.ReadStatusAsync) but nothing consumed it. +/// +[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 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) { } + } + } +}