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
+3
View File
@@ -1105,6 +1105,9 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
`BadNotSupported`). `BadNotSupported`).
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown. - **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only).
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync` - **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
+74 -3
View File
@@ -146,7 +146,7 @@ reference them will throw at startup with a message pointing here.
| Capability | Wire path | Notes | | Capability | Wire path | Notes |
|------------|-----------|-------| |------------|-----------|-------|
| `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally | | `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally |
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented | | `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented, and none will be (policy: the stranded write/unlock backlog items #268–#271 are won't-do; a write path on a production CNC is a safety decision, not a feature gap) |
| `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device | | `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device |
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups | | `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` | | `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
@@ -224,15 +224,36 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
| `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick | | `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick |
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` | | `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` | | `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
| `Status/` | `Auto`, `Run`, `Motion`, `Mstb`, `Emergency`, `Alarm`, `Edit`, `TmMode` (raw FANUC codes, Int32) + `RunText` ("RESET", "STOP", "HOLD", "START", "MSTR") — see [CNC status flags](#cnc-status-flags-status) | `cnc_statinfo` (polled on `ProgramPollInterval`) |
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 | | `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
### CNC status flags (`Status/`)
`cnc_statinfo` (the `ODBST` block) is exposed as read-only `FIXED:Status/<field>` nodes (#257). Values are the
control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported:
| Field | Codes |
|---|---|
| `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) |
| `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) |
| `Motion` | 0 none, 1 axis motion, 2 dwell |
| `Mstb` | 0 idle, 1 executing an M/S/T/B function |
| `Emergency` | 0 not, 1 emergency stop, 2 reset |
| `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) |
| `Edit`, `TmMode` | program-edit state; T/M mode |
Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String).
Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no
`Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they
issue no wire traffic and go `BadCommunicationError` once the device has stopped answering.
### Per-series node suppression ### Per-series node suppression
The driver probes each optional call once at bootstrap. If the target CNC The driver probes each optional call once at bootstrap. If the target CNC
returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the
corresponding subtree is **not emitted** — the operator doesn't see nodes corresponding subtree is **not emitted** — the operator doesn't see nodes
that will only ever return `BadDeviceFailure`. Capability suppression that will only ever return `BadDeviceFailure`. Capability suppression
covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and covers `Spindle/`, `Program/` + `OperationMode/`, `Status/`, `Timers/`, and
per-axis `ServoLoad` independently. Identity + `Axes/*` position reads per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
(which every Fanuc CNC supports) are always emitted. (which every Fanuc CNC supports) are always emitted.
@@ -265,6 +286,54 @@ fallen back to `PositionDecimalPlaces` and published raw counts.
numbers whose meaning is MTB-specific, and which no browse can discover. Those numbers whose meaning is MTB-specific, and which no browse can discover. Those
go under the device folder alongside the fixed subtree. go under the device folder alongside the fixed subtree.
### Parts count (`PARAM:6711`)
There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC parameter, so it
is already readable as a hand-authored `PARAM:` tag (Int32), and a fixed `Parts/` subtree would hard-code
parameter numbers that are control- and MTB-specific. On the standard FANUC 0i / 16i / 30i parameter map:
| Address | Parameter | Meaning |
|---|---|---|
| `PARAM:6711` | No. 6711 | Number of parts machined (resets with the operator's clear) |
| `PARAM:6712` | No. 6712 | Total number of parts machined (cumulative) |
| `PARAM:6713` | No. 6713 | Number of parts required (the target) |
The count-up trigger (which M code or cycle end increments it) is set by No. 6710 and bit 0 of No. 6700,
so confirm in the machine's parameter manual that the counter is actually enabled and that 6711–6713 mean
what the MTB says they mean before relying on them. Cycle time per part is not provided; the `Timers/`
subtree exposes `CycleSeconds` and a client can difference it across a `PARAM:6711` change.
## PMC range coalescing
A poll batch that reads several nearby PMC tags used to issue one `pmc_rdpmcrng` per tag. The driver now
merges tags of the same PMC letter and width that sit close together into **one** range read and slices each
tag's value out of the result (#266). Single, isolated PMC tags — the common "one tag per area" case — are
read exactly as before.
```jsonc
"PmcCoalesce": {
"Enabled": true, // default
"MaxSpanBytes": 128, // hard cap on one merged range
"MaxGapBytes": 8 // bytes nobody asked for tolerated between two merged tags; 0 = only strictly adjacent
}
```
Rules worth knowing:
- Tags merge only within the same letter and width class (Bit and Byte are one class; Word, Long, Float, Double
are separate). A multi-byte tag joins a range only when its offset from the range start is a multiple of its
width, because the CNC splits a range into width-sized slots from the start.
- **Spare gap bytes must be valid PMC addresses.** A gap that crosses a hole in the CNC's PMC map makes the CNC
refuse the whole range. That is handled, not fatal: a refused range is remembered per device and its tags are
read **individually**, each with its own true status (a refusal is never stamped onto healthy members). If
that happens routinely, lower `MaxGapBytes` (0 disables gap-spanning entirely) or set `Enabled` to `false`.
- A transport failure or timeout on the merged read fails every member with `BadCommunicationError` and does
**not** retry per tag — the link is down, so per-tag retries would only multiply timeouts.
- Read-only: this changes `pmc_rdpmcrng` reads only; nothing here can write.
The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown
key edited through that form is dropped), so set it in the raw config.
## Alarm projection ## Alarm projection
Alarm surfacing is **disabled by default** because the polling cost is wasted Alarm surfacing is **disabled by default** because the polling cost is wasted
@@ -324,7 +393,9 @@ latency spike once per cadence.
driver surface via `FakeFocasClient`. Includes the alarm-projection raise / driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
clear diffing tests. clear diffing tests.
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` - **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
hold the Docker simulator scaffold; see hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent;
`Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and
`pmc` state); see
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md) [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
for what the simulator emits vs. real CNC behaviour. for what the simulator emits vs. real CNC behaviour.
- **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real - **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real
@@ -31,6 +31,8 @@ public sealed class FocasDriverOptions
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new(); public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
/// <summary>Gets the fixed tree options.</summary> /// <summary>Gets the fixed tree options.</summary>
public FocasFixedTreeOptions FixedTree { get; init; } = new(); public FocasFixedTreeOptions FixedTree { get; init; } = new();
/// <summary>Gets the PMC range-coalescing options (Gitea #266).</summary>
public FocasPmcCoalesceOptions PmcCoalesce { get; init; } = new();
/// <summary> /// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a /// 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; 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> /// <summary>
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> — /// 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 /// <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 now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count]; 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++) for (var i = 0; i < fullReferences.Count; i++)
{ {
if (served is not null && served[i]) continue;
var reference = fullReferences[i]; var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached // 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; 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 ---- // ---- IWritable ----
/// <inheritdoc /> /// <inheritdoc />
@@ -751,6 +880,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String); 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). // Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
if (cache.Capabilities.Timers) if (cache.Capabilities.Timers)
{ {
@@ -1008,6 +1146,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []); var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct)); var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, 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 spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
var caps = new FocasFixedTreeCapabilities( var caps = new FocasFixedTreeCapabilities(
@@ -1016,7 +1155,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0, SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
ServoLoad: servoLoads.Count > 0, ServoLoad: servoLoads.Count > 0,
ProgramInfo: programInfo is not null, 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); state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
// The bootstrap IS an answer from the device, and Identity comes entirely from it — // 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 programPollDue = DateTime.MinValue;
var timerPollDue = DateTime.MinValue; var timerPollDue = DateTime.MinValue;
var statusPollDue = DateTime.MinValue;
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
try try
@@ -1129,6 +1271,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
programPollDue = DateTime.UtcNow + programInterval; 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). // Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
var timerInterval = _options.FixedTree.TimerPollInterval; var timerInterval = _options.FixedTree.TimerPollInterval;
if (cache.Capabilities.Timers 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) => private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null; 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/Mode" => state.LastProgramInfo?.Mode,
"OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode "OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode
? FocasOpMode.ToText(mode) : null, ? 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/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating), "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting), "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 SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work 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) internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{ {
@@ -1683,6 +1851,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// holding no value. /// holding no value.
/// </summary> /// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase); 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> /// <summary>Gets or sets the last program information snapshot.</summary>
public FocasProgramInfo? LastProgramInfo { get; set; } public FocasProgramInfo? LastProgramInfo { get; set; }
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary> /// <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), FixedTree = BuildFixedTree(dto.FixedTree),
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection), AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle), HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
PmcCoalesce = BuildPmcCoalesce(dto.PmcCoalesce),
}; };
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory)); 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) private static FocasCncSeries ParseSeries(string? raw)
{ {
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown; 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> /// <summary>Gets or sets the handle recycle configuration options.</summary>
public FocasHandleRecycleDto? HandleRecycle { get; init; } 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 internal sealed class FocasDeviceDto
@@ -348,6 +365,17 @@ public static class FocasDriverFactoryExtensions
public TimeSpan? PollInterval { get; init; } 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> /// <summary>Optional <c>HandleRecycle</c> config section.</summary>
internal sealed class FocasHandleRecycleDto 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> /// <returns>The list of currently active alarms; empty when none are active.</returns>
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken); 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) ---- // ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
/// <summary> /// <summary>
@@ -125,6 +143,17 @@ public interface IFocasClient : IDisposable
/// <returns>The aggregate program + operation-mode snapshot.</returns> /// <returns>The aggregate program + operation-mode snapshot.</returns>
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken); 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) ---- // ---- Fixed-tree T3 (timers) ----
/// <summary> /// <summary>
@@ -172,6 +201,38 @@ public interface IFocasClient : IDisposable
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken); 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> /// <summary>
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record /// One servo-meter entry — one axis's current load percentage; null when the CNC's record
/// described no usable value (never a fabricated 0). /// described no usable value (never a fabricated 0).
@@ -61,6 +61,11 @@ public sealed class SynchronizedFocasClient : IFocasClient
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) => FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), 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 /> /// <inheritdoc />
public Task<uint> WriteAsync( public Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
@@ -90,6 +95,10 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) => public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken); RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken);
/// <inheritdoc /> /// <inheritdoc />
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), 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 /> /// <inheritdoc />
public Task<uint> WriteAsync( public Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
@@ -129,6 +156,18 @@ public sealed class WireFocasClient : IFocasClient
AxesCount: axesCount); 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 /> /// <inheritdoc />
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
{ {
@@ -313,17 +352,8 @@ public sealed class WireFocasClient : IFocasClient
return (null, FocasStatusMapper.MapFocasReturn(result.Rc)); return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var values = result.Value.Values; var values = result.Value.Values;
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange); if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
var raw = values[0]; // One decode authority shared with the coalesced path (FocasPmcCoalescer.MapSlot).
var mapped = type switch var mapped = FocasPmcCoalescer.MapSlot(values[0], type, address.BitIndex);
{
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,
};
return (mapped, FocasStatusMapper.Good); return (mapped, FocasStatusMapper.Good);
} }
catch (FocasWireException ex) catch (FocasWireException ex)
@@ -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)); 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> /// <summary>Writes a value to a FOCAS address asynchronously.</summary>
/// <param name="address">The FOCAS address to write to.</param> /// <param name="address">The FOCAS address to write to.</param>
/// <param name="type">The data type of the value.</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) => public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
Task.FromResult(ProgramInfo); 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> /// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = []; public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
/// <summary>Gets timer data asynchronously.</summary> /// <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) { }
}
}
}