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>