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
@@ -31,6 +31,8 @@ public sealed class FocasDriverOptions
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
/// <summary>Gets the fixed tree options.</summary>
public FocasFixedTreeOptions FixedTree { get; init; } = new();
/// <summary>Gets the PMC range-coalescing options (Gitea #266).</summary>
public FocasPmcCoalesceOptions PmcCoalesce { get; init; } = new();
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
@@ -41,6 +43,32 @@ public sealed class FocasDriverOptions
public int ProbeTimeoutSeconds { get; init; } = 10;
}
/// <summary>
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
/// On by default: a batch with a single PMC tag per area is unaffected, and a coalesced read the CNC
/// rejects falls back to per-tag reads, so enabling it cannot make a readable tag unreadable.
/// </summary>
public sealed class FocasPmcCoalesceOptions
{
/// <summary>Gets a value indicating whether PMC coalescing is on.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Largest range, in bytes, one coalesced read may cover. Bounds the response size and the
/// blast radius of a rejected range. Default 128.
/// </summary>
[Range(1, 1024)]
public int MaxSpanBytes { get; init; } = 128;
/// <summary>
/// Largest run of bytes nobody asked for that may sit between two merged tags. Reading a few
/// spare bytes is cheaper than a second round-trip, but every spare byte must be a valid PMC
/// address on the CNC. Default 8; 0 merges only strictly adjacent tags.
/// </summary>
[Range(0, 1024)]
public int MaxGapBytes { get; init; } = 8;
}
/// <summary>
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
@@ -498,8 +498,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
// serves are marked here and skipped by the per-tag loop below; everything else (including every
// single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
var served = _options.PmcCoalesce.Enabled
? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false)
: null;
for (var i = 0; i < fullReferences.Count; i++)
{
if (served is not null && served[i]) continue;
var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
@@ -581,6 +589,127 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
/// <summary>
/// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges
/// (<see cref="FocasPmcCoalescer"/>) and serves every multi-member range with ONE
/// <c>pmc_rdpmcrng</c>, slicing each tag's value out of the returned slots.
/// </summary>
/// <remarks>
/// Failure handling is the point of the design. A rejected range is NOT mis-attributed to its members:
/// <list type="bullet">
/// <item>a comms-level failure (timeout / transport) fails every member with that status — the
/// link is down, so re-reading each member would multiply timeouts;</item>
/// <item>any other non-Good status (typically a spare "gap" byte that is not a valid PMC address)
/// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own
/// true status — and the range is remembered so later polls skip the doomed merged read.</item>
/// </list>
/// Returns null when nothing was served.
/// </remarks>
private async Task<bool[]?> CoalescePmcReadsAsync(
IReadOnlyList<string> references, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
Dictionary<DeviceState, List<FocasPmcReadItem>>? byDevice = null;
for (var i = 0; i < references.Count; i++)
{
try
{
var reference = references[i];
if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
if (!_resolver.TryResolve(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
var parsed = ResolveParsedAddress(def);
if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
(byDevice ??= []).TryAdd(device, []);
byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
/* malformed / unresolvable — the per-tag loop reports it with its usual status */
}
}
if (byDevice is null) return null;
bool[]? served = null;
foreach (var (device, items) in byDevice)
{
if (items.Count < 2) continue;
var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue;
// One connect attempt per device per batch: if it fails, every PMC tag of the device fails with
// that status here instead of paying a connect attempt per plan and again per tag.
IFocasClient client;
try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception)
{
FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
$"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}");
continue;
}
foreach (var plan in plans)
{
if (plan.Members.Count < 2) continue;
if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
var members = plan.Members;
try
{
var (slots, status) = await client
.ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
.ConfigureAwait(false);
if (status == FocasStatusMapper.Good && slots is not null)
{
foreach (var m in members)
{
var slot = plan.SlotOf(m);
results[m.Index] = slot < slots.Count
? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
: new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
(served ??= new bool[references.Count])[m.Index] = true;
}
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
}
else
{
// The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which
// member is at fault: let the per-tag loop give each its own answer. Remember the range
// so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to
// BadDeviceFailure), which says nothing about the range.
if (status != FocasStatusMapper.BadDeviceFailure)
device.CoalesceRejected[plan.Key] = 0;
_logger.LogDebug(
"FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
plan.Key, device.Options.HostAddress, status, members.Count);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception)
{
// Transport failure / timeout: the link is suspect, so fail the members without per-tag retries.
FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
$"FOCAS transport failure reading PMC range {plan.Key}");
}
}
}
return served;
void FailMembers(IEnumerable<FocasPmcReadItem> failed, uint status, IReadOnlyList<string> refs,
DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason)
{
foreach (var m in failed)
{
res[m.Index] = new DataValueSnapshot(null, status, null, stamp);
(servedRef ??= new bool[refs.Count])[m.Index] = true;
}
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
Volatile.Read(ref _health).LastSuccessfulRead,
$"{reason} (0x{status:X8})"));
}
}
// ---- IWritable ----
/// <inheritdoc />
@@ -751,6 +880,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 +1146,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 +1155,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 +1195,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 +1271,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 +1401,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 +1543,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 +1785,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 +1851,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// holding no value.
/// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// PMC ranges (<see cref="FocasPmcRangePlan.Key"/>) the CNC rejected as a merged read, so later polls skip
/// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266).
/// </summary>
public ConcurrentDictionary<string, byte> CoalesceRejected { get; } = new(StringComparer.Ordinal);
/// <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>
@@ -115,6 +115,7 @@ public static class FocasDriverFactoryExtensions
FixedTree = BuildFixedTree(dto.FixedTree),
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
PmcCoalesce = BuildPmcCoalesce(dto.PmcCoalesce),
};
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory));
@@ -206,6 +207,19 @@ public static class FocasDriverFactoryExtensions
};
}
/// <summary>Map the optional <c>PmcCoalesce</c> section; a missing section or key keeps the default.</summary>
private static FocasPmcCoalesceOptions BuildPmcCoalesce(FocasPmcCoalesceDto? dto)
{
var defaults = new FocasPmcCoalesceOptions();
if (dto is null) return defaults;
return new FocasPmcCoalesceOptions
{
Enabled = dto.Enabled ?? defaults.Enabled,
MaxSpanBytes = dto.MaxSpanBytes is > 0 ? dto.MaxSpanBytes.Value : defaults.MaxSpanBytes,
MaxGapBytes = dto.MaxGapBytes is >= 0 ? dto.MaxGapBytes.Value : defaults.MaxGapBytes,
};
}
private static FocasCncSeries ParseSeries(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
@@ -284,6 +298,9 @@ public static class FocasDriverFactoryExtensions
/// <summary>Gets or sets the handle recycle configuration options.</summary>
public FocasHandleRecycleDto? HandleRecycle { get; init; }
/// <summary>Gets or sets the PMC range-coalescing configuration options.</summary>
public FocasPmcCoalesceDto? PmcCoalesce { get; init; }
}
internal sealed class FocasDeviceDto
@@ -348,6 +365,17 @@ public static class FocasDriverFactoryExtensions
public TimeSpan? PollInterval { get; init; }
}
/// <summary>Optional <c>PmcCoalesce</c> config section (#266).</summary>
internal sealed class FocasPmcCoalesceDto
{
/// <summary>Gets or sets a value indicating whether coalescing is on.</summary>
public bool? Enabled { get; init; }
/// <summary>Gets or sets the largest coalesced range in bytes.</summary>
public int? MaxSpanBytes { get; init; }
/// <summary>Gets or sets the largest tolerated gap in bytes.</summary>
public int? MaxGapBytes { get; init; }
}
/// <summary>Optional <c>HandleRecycle</c> config section.</summary>
internal sealed class FocasHandleRecycleDto
{
@@ -0,0 +1,137 @@
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>One PMC read the coalescer may merge — the caller's batch index plus the parsed address and type.</summary>
/// <param name="Index">Position in the caller's batch; results are sliced back to it.</param>
/// <param name="Letter">PMC area letter (<c>R</c>, <c>X</c>, ...), upper-case.</param>
/// <param name="Number">Byte address.</param>
/// <param name="Type">The tag's declared data type (decides slot width and decode).</param>
/// <param name="BitIndex">Bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
internal readonly record struct FocasPmcReadItem(int Index, string Letter, int Number, FocasDataType Type, int? BitIndex);
/// <summary>
/// One planned <c>pmc_rdpmcrng</c> read covering <see cref="Members"/>. <see cref="End"/> is inclusive and is a
/// whole number of <see cref="SlotWidth"/>-byte slots from <see cref="Start"/>.
/// </summary>
internal sealed record FocasPmcRangePlan(
string Letter,
FocasDataType SlotType,
int SlotWidth,
int Start,
int End,
IReadOnlyList<FocasPmcReadItem> Members)
{
/// <summary>Stable key for the "this range was rejected, read members individually" memo.</summary>
public string Key => $"{Letter}:{SlotType}:{Start}-{End}";
/// <summary>Slot index (into the decoded range) that holds <paramref name="item"/>'s value.</summary>
/// <param name="item">A member of this plan.</param>
/// <returns>The zero-based slot index.</returns>
public int SlotOf(FocasPmcReadItem item) => (item.Number - Start) / SlotWidth;
}
/// <summary>
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
/// </summary>
/// <remarks>
/// <para>Items are grouped by <c>(letter, slot width class)</c> and merged in address order while (a) the gap to
/// the previous member is at most <c>maxGapBytes</c> and (b) the whole range stays within <c>maxSpanBytes</c>.
/// A multi-byte member only joins a range when its offset from the range start is a multiple of its width — the
/// CNC splits a range into width-sized slots from the start, so an unaligned member would be decoded from the
/// wrong bytes. Members that cannot merge become single-member plans; the driver reads those through the
/// ordinary per-tag path, so the common "one tag per area" case is byte-for-byte unchanged.</para>
/// <para>Pure and deterministic: no I/O, no clock.</para>
/// </remarks>
internal static class FocasPmcCoalescer
{
/// <summary>Plans the reads for a batch.</summary>
/// <param name="items">The PMC reads in the batch (any order).</param>
/// <param name="maxSpanBytes">Largest range, in bytes, one coalesced read may cover (&gt;= 1).</param>
/// <param name="maxGapBytes">Largest run of unrequested bytes tolerated between two members (&gt;= 0).</param>
/// <returns>Range plans; every input item appears in exactly one plan.</returns>
public static IReadOnlyList<FocasPmcRangePlan> Plan(
IEnumerable<FocasPmcReadItem> items, int maxSpanBytes, int maxGapBytes)
{
maxSpanBytes = Math.Max(1, maxSpanBytes);
maxGapBytes = Math.Max(0, maxGapBytes);
var plans = new List<FocasPmcRangePlan>();
var groups = items
.GroupBy(i => (Letter: i.Letter.ToUpperInvariant(), Slot: SlotTypeOf(i.Type)))
.OrderBy(g => g.Key.Letter, StringComparer.Ordinal).ThenBy(g => g.Key.Slot);
foreach (var group in groups)
{
var width = FocasWireClient.PmcByteWidth((short)FocasPmcDataTypeLookup.FromFocasDataType(group.Key.Slot));
var sorted = group.OrderBy(i => i.Number).ThenBy(i => i.Index).ToList();
List<FocasPmcReadItem>? members = null;
var start = 0;
var end = 0; // inclusive
void Flush()
{
if (members is null) return;
// Round the range up to a whole number of slots so the CNC returns no partial trailing slot.
var bytes = end - start + 1;
var rounded = (bytes + width - 1) / width * width;
plans.Add(new FocasPmcRangePlan(group.Key.Letter, group.Key.Slot, width, start, start + rounded - 1, members));
members = null;
}
foreach (var item in sorted)
{
var itemEnd = item.Number + width - 1;
if (members is not null)
{
var gap = Math.Max(0, item.Number - (end + 1));
var aligned = (item.Number - start) % width == 0;
var newEnd = Math.Max(end, itemEnd);
// The span check is on the ROUNDED length a flush would produce.
var roundedSpan = (newEnd - start + 1 + width - 1) / width * width;
if (aligned && gap <= maxGapBytes && roundedSpan <= maxSpanBytes)
{
members.Add(item);
end = newEnd;
continue;
}
Flush();
}
members = [item];
start = item.Number;
end = itemEnd;
}
Flush();
}
return plans;
}
/// <summary>The data type whose wire width a read of <paramref name="type"/> uses (Bit and Byte share one).</summary>
/// <param name="type">The tag's declared type.</param>
/// <returns>The representative slot type.</returns>
public static FocasDataType SlotTypeOf(FocasDataType type) => type switch
{
FocasDataType.Bit or FocasDataType.Byte => FocasDataType.Byte,
_ => type,
};
/// <summary>
/// Decodes one raw PMC slot (as <c>pmc_rdpmcrng</c> parsed it) into the tag's published value. The single
/// decode authority for both the per-tag and the coalesced path.
/// </summary>
/// <param name="raw">The slot value.</param>
/// <param name="type">The tag's declared type.</param>
/// <param name="bitIndex">Bit index for a Bit tag.</param>
/// <returns>The boxed value.</returns>
public static object MapSlot(long raw, FocasDataType type, int? bitIndex) => type switch
{
FocasDataType.Bit => (object)(((raw >> (bitIndex ?? 0)) & 1L) != 0),
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
FocasDataType.Int16 => (object)(short)raw,
FocasDataType.Int32 => (object)(int)raw,
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
_ => (object)raw,
};
}
@@ -73,6 +73,24 @@ public interface IFocasClient : IDisposable
/// <returns>The list of currently active alarms; empty when none are active.</returns>
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read one PMC range (<c>pmc_rdpmcrng</c>) as raw slots: <c>(end - start + 1)</c> bytes split into
/// slots of <paramref name="slotType"/>'s width, from <paramref name="start"/>. The primitive behind PMC
/// range coalescing (Gitea #266) — the driver slices individual tag values out of the returned slots with
/// the same decode a per-tag read uses.
/// </summary>
/// <param name="letter">PMC area letter.</param>
/// <param name="slotType">Decides the slot width (Bit/Byte = 1, Int16 = 2, Int32/Float32 = 4, Float64 = 8).</param>
/// <param name="start">First byte address.</param>
/// <param name="end">Last byte address, inclusive; a whole number of slots from <paramref name="start"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The raw slot values (null when not Good) and the mapped OPC UA status. A returned non-Good status is
/// always a CNC return-code refusal; a transport failure or timeout throws instead.
/// </returns>
Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken);
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
/// <summary>
@@ -125,6 +143,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 +201,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).
@@ -61,6 +61,11 @@ public sealed class SynchronizedFocasClient : IFocasClient
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken);
/// <inheritdoc />
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadPmcSlotsAsync(letter, slotType, start, end, ct), cancellationToken);
/// <inheritdoc />
public Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
@@ -90,6 +95,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);
@@ -67,6 +67,33 @@ public sealed class WireFocasClient : IFocasClient
};
}
/// <inheritdoc />
public async Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken)
{
// Contract: a RETURNED status is always a CNC return code (a refusal); a transport failure THROWS.
// The coalescing caller needs that distinction — it falls back per tag on a refusal but must not
// re-hit a dead link (#266).
RequireConnected();
cancellationToken.ThrowIfCancellationRequested();
var area = FocasPmcAreaLookup.FromLetter(letter);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
try
{
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
.ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
return (result.Value.Values, FocasStatusMapper.Good);
}
catch (FocasWireException ex) when (ex.Rc is short rc)
{
return (null, FocasStatusMapper.MapFocasReturn(rc));
}
}
/// <inheritdoc />
public Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
@@ -129,6 +156,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)
{
@@ -313,17 +352,8 @@ public sealed class WireFocasClient : IFocasClient
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var values = result.Value.Values;
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
var raw = values[0];
var mapped = type switch
{
FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
FocasDataType.Int16 => (object)(short)raw,
FocasDataType.Int32 => (object)(int)raw,
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
_ => (object)raw,
};
// One decode authority shared with the coalesced path (FocasPmcCoalescer.MapSlot).
var mapped = FocasPmcCoalescer.MapSlot(values[0], type, address.BitIndex);
return (mapped, FocasStatusMapper.Good);
}
catch (FocasWireException ex)