feat(focas): cnc_statinfo status flags as Status/ fixed-tree nodes (#257)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:53:44 -04:00
parent a8b07ae12d
commit 5b36c717de
7 changed files with 379 additions and 2 deletions
@@ -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.
/// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets or sets the last <c>cnc_statinfo</c> snapshot (#257); null until the first answer.</summary>
public FocasStatus? LastStatus { get; set; }
/// <summary>Gets or sets the last program information snapshot.</summary>
public FocasProgramInfo? LastProgramInfo { get; set; }
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
@@ -125,6 +125,17 @@ public interface IFocasClient : IDisposable
/// <returns>The aggregate program + operation-mode snapshot.</returns>
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ----
/// <summary>
/// Read the CNC status flags via <c>cnc_statinfo</c> (<c>ODBST</c>). Populates the
/// <c>Status/*</c> 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.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The CNC status flag snapshot.</returns>
Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3 (timers) ----
/// <summary>
@@ -172,6 +183,38 @@ public interface IFocasClient : IDisposable
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
}
/// <summary>
/// CNC status flags from <c>cnc_statinfo</c> (<c>ODBST</c>) — raw FANUC codes, published verbatim as
/// Int32 so a client sees exactly what the control reported. <see cref="Run"/> additionally has a
/// text projection (<see cref="FocasRunState.ToText"/>).
/// </summary>
/// <param name="Auto">Automatic-operation mode code.</param>
/// <param name="Run">Automatic-operation state (0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR).</param>
/// <param name="Motion">Axis motion (0 none, 1 motion, 2 dwell).</param>
/// <param name="Mstb">M/S/T/B function state (0 none, 1 executing).</param>
/// <param name="Emergency">Emergency stop (0 not, 1 emergency, 2 reset).</param>
/// <param name="Alarm">Alarm state (0 none, 1 alarm, 2 battery-low, 3 fan).</param>
/// <param name="Edit">Program-edit state.</param>
/// <param name="TmMode">T/M mode as reported by the control.</param>
public sealed record FocasStatus(int Auto, int Run, int Motion, int Mstb, int Emergency, int Alarm, int Edit, int TmMode);
/// <summary>Human-readable text for <see cref="FocasStatus.Run"/>.</summary>
public static class FocasRunState
{
/// <summary>Converts the <c>ODBST.run</c> code to its operator-facing label.</summary>
/// <param name="run">The run-state code.</param>
/// <returns>The label, or <c>Run{n}</c> for a code outside the documented set.</returns>
public static string ToText(int run) => run switch
{
0 => "RESET",
1 => "STOP",
2 => "HOLD",
3 => "START",
4 => "MSTR",
_ => $"Run{run}",
};
}
/// <summary>
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
/// described no usable value (never a fabricated 0).
@@ -90,6 +90,10 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken);
@@ -129,6 +129,18 @@ public sealed class WireFocasClient : IFocasClient
AxesCount: axesCount);
}
/// <inheritdoc />
public async Task<FocasStatus> 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);
/// <inheritdoc />
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
{
@@ -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;
/// <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);
}
}
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);
}
}
}
@@ -121,6 +121,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,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) { }
}
}
}