Merge FOCAS wire-decode corrections ported from ignitionoee

This commit is contained in:
Joseph Doherty
2026-10-07 14:17:58 -04:00
37 changed files with 1782 additions and 583 deletions
+9 -4
View File
@@ -61,16 +61,21 @@ binary PDUs against the documented command IDs. Covered assertions:
- Session open / close (`cnc_allclibhndl3` + `cnc_freelibhndl`)
- Parameter read-back after `mock_patch` seed → `cnc_rdparam`
- Macro read-back after seed → `cnc_rdmacro` (scaled-decimal
translation verified)
- Macro read-back after seed → `cnc_rdmacro` (8-byte ODBM shape:
9-digit mantissa, `dec_val` at offset 6; vacant / fractional-on-integer
macros read Bad)
- PMC range read after seed → `pmc_rdpmcrng`
- `IAlarmSource` raise + clear transitions after `mock_patch`
alarm-list changes → `cnc_rdalmmsg2`
- Fixed-tree bootstrap: identity / axes / spindle / program / timers /
servo meters populate via `cnc_sysinfo`, `cnc_rdaxisname`,
`cnc_rdspdlname`, `cnc_rddynamic2`, `cnc_exeprgname2`,
`cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspload`,
`cnc_rdspmaxrpm`, `cnc_rdtimer`
`cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspmeter`
(load + motor speed, 1/16384 of full scale), `cnc_rdtimer`; positions
scale from each `cnc_rddynamic2` block's own base/exponent (the mock
refuses the non-existent `cnc_getfigure` 0x00D3, as a real 31i-B does).
The vendored mock's local divergence from upstream is listed in
`Docker/focas-mock/VENDORED.md`.
- Per-series profile selection via `mock_load_profile` — tests can
pin one profile and assert series-gated capability suppression
+55 -21
View File
@@ -31,8 +31,10 @@ protocol using the documented command IDs. Writes return
> 31i-B (`10.201.31.5`) 2026-06-25:** sysinfo, axis/spindle names, dynamic positions/feed/spindle,
> program/mode, **macros**, **timers** (8-byte payload is little-endian {minute, msec}), **PMC range**
> (request widened to the data-type byte width), **servo meters** (8-byte LOADELM stride + names from
> the 0x0089 block; load *scaling* still to be confirmed at commissioning), and **alarms** all read
> correctly. The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this
> the 0x0089 block), and **alarms** all read correctly. Several *decode* errors that survived that
> validation (because they decode without failing — just wrongly) were later corrected from the
> sister ignitionoee driver's live work on the same family of controls; see
> [Wire decode corrections (2026-10)](#wire-decode-corrections-2026-10). The driver `cnc_rddynamic2` poll is 1-based. **`cnc_rdparam` is unsupported on this
> control** — every request-framing variant returns `EW_FUNC` (likely a wrong v3 command id; needs a
> reference FWLIB trace). The Test-Connect probe now runs a real wire session (initiate + cnc_statinfo)
> instead of a bare TCP check. Full finding, captured wire bytes, validation results, and analysis:
@@ -187,11 +189,14 @@ fixed-tree paths are discovered from the machine, not composed from parts.
Two caveats worth knowing before you browse:
- **A real CNC can report duplicate sibling names, and they are disambiguated
automatically** (#548). One observed Makino returns two axes both called `AA`;
every member of such a collision set gets a 1-based occurrence suffix in slot
order — the tree offers `Axes/AA_1/` and `Axes/AA_2/`, each reading its own
axis — while unique names stay bare (`Axes/Y/`). The bare name of a duplicate
- **A CNC that reports duplicate sibling names is disambiguated automatically**
(#548): every member of a collision set gets a 1-based occurrence suffix in slot
order (`Axes/AA_1/`, `Axes/AA_2/`), each reading its own axis, while unique names
stay bare (`Axes/Y/`). ⚠️ The Makino that motivated #548 does **not** in fact
report duplicates — its axes are `AA6` and `AA7`, and the wire layer used to read
only 2 of each 4-byte name record. Since the name-record fix those axes publish as
`Axes/AA6/` and `Axes/AA7/`; tags committed against `Axes/AA_1/…` / `Axes/AA_2/…`
on such a machine must be re-browsed and re-committed. The bare name of a duplicate
set deliberately does not resolve: a tag authored against `Axes/AA/…` on such
a machine reads `BadNodeIdUnknown` rather than one arbitrary axis's data. The
suffix counts occurrences of the *same* name, so adding an unrelated axis
@@ -212,8 +217,8 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
| `Identity/` | `SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` | `cnc_sysinfo` once at bootstrap |
| `Axes/{name}/` | `AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` — one folder per discovered axis | `cnc_rdaxisname` once + `cnc_rddynamic2` + `cnc_rdsvmeter` per tick |
| `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` | Current feed + spindle RPM | `cnc_rddynamic2` |
| `Spindle/{name}/` | `Load` (percentage), `MaxRpm` — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspload` + `cnc_rdspmaxrpm` |
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
| `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` |
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
@@ -228,27 +233,29 @@ per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
(which every Fanuc CNC supports) are always emitted.
Position values (`AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`)
are CNC-internal scaled integers exposed as `Float64` OPC UA nodes. The driver converts them
to engineering units using the per-device `PositionDecimalPlaces` config field (default `0`
= no scaling). When set to a positive integer *d*, each position value is divided by `10^d`
before publishing, so a CNC that reports millimetres × 1000 is corrected by setting
`PositionDecimalPlaces: 3`.
are published as `Float64` in engineering units. **Each `cnc_rddynamic2` position block carries
its own scale** — `[i32 raw][u16 base][u16 exponent]`, value = `raw / base^exponent` (a real
31i-B reports base 10 / exponent 4: `2801574` → `280.1574` mm) — and the driver always applies
it. A block that describes no usable position (the `0xFFFF` vacant-slot marker, a base other
than 2 or 10, an exponent > 18) publishes `BadNoData` on that node alone.
`PositionDecimalPlaces` (per device, default `0`) is now only the **fallback for a block that
carries no scale** (a 4-byte payload, e.g. an older simulator): such a position is divided by
`10^PositionDecimalPlaces`. Negative values are clamped to `0`.
```jsonc
"Devices": [
{
"HostAddress": "focas://10.20.30.40:8193",
"Series": "ThirtyOne_i",
"PositionDecimalPlaces": 3 // 123456 → 123.456 mm
"PositionDecimalPlaces": 3 // only used if the CNC's blocks carry no scale
}
]
```
The driver now attempts to auto-scale each axis position using the CNC's `cnc_getfigure` figure when available; `PositionDecimalPlaces` is the **per-axis fallback** used only when the CNC reports no figure for that axis. **Caveat:** the managed `WireFocasClient` (FOCAS/2 Ethernet wire protocol) does not issue `cnc_getfigure` today, so on real hardware all axes still fall back to the manual `PositionDecimalPlaces` config field. Live auto-fetch is a follow-up requiring a `cnc_getfigure` command in `FocasWireClient`. Negative values from `PositionDecimalPlaces` are clamped to `0`.
**Follow-ups (known gaps, non-blocking):**
- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow `Math.Pow`; a max-figure guard should be added.
- The negative-figure branch (falls back to manual `PositionDecimalPlaces`) is handled but not explicitly unit-tested.
There is no `cnc_getfigure` read: the `0x00D3` command the driver used to send is not a FOCAS
wire command (a real 31i-B refuses it with `rc=1`), so on hardware every axis had silently
fallen back to `PositionDecimalPlaces` and published raw counts.
**Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
numbers whose meaning is MTB-specific, and which no browse can discover. Those
@@ -330,7 +337,34 @@ latency spike once per cadence.
| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
| Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config |
| Axis position values seem 1000× too large | `PositionDecimalPlaces` not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]` |
| Axis position values seem 1000× too large | The CNC's position blocks carry no scale (older control / simulator) and `PositionDecimalPlaces` is not set | Add `"PositionDecimalPlaces": 3` (or the MTB-specific value) to the device entry in `Devices[]`. A real 30i/31i carries the scale in each block and needs no setting |
| A fixed-tree node or `MACRO:` tag reads `BadNoData` | The CNC answered but described no value — a vacant macro variable, an undescribable position/meter record, a program field it did not report | Check the address on the control; this is the control's answer, not a comms fault |
| An integer-typed `MACRO:` tag reads `BadTypeMismatch` | The macro holds a fractional value (e.g. `148.5`) | Author the tag as `Float64`, or point it at a whole-valued variable — a fraction is never truncated into an integer tag |
## Wire decode corrections (2026-10)
Each of these decoded without ever failing — just wrongly — so live validation that only checked
"the read succeeds" passed them. All were found by the sister **ignitionoee** FOCAS driver
against a real Makino 31i-B (Z-34184, series G431) and ported here with its captured payloads as
test fixtures (`tests/.../Driver.FOCAS.Tests/Fixtures/v3/`). Client-visible changes are marked ⚠️.
| What | Before | Now |
|------|--------|-----|
| `cnc_rdmacro` decimal | read at payload offset 4 — a constant `10` length field — so `#500 = 1.0` published `0.01` | `dec_val` at offset 6; `mantissa / 10^dec`. Vacant variable → `BadNoData`; fractional value on an integer tag → `BadTypeMismatch`; integer tags receive the exact whole value |
| Axis positions | scaled by `cnc_getfigure` (0x00D3), which does not exist — on hardware: raw counts | scaled by each block's own `base^exponent`; undescribable block → `BadNoData` |
| Axis / spindle names | first 2 of 4 record bytes — `AA6`/`AA7` collapsed to `AA` | whole record up to the first NUL (residue after it discarded) — ⚠️ `Axes/AA_1`→`Axes/AA6` on such a machine |
| `ServoLoad` | `value / 10^base` (offset 4 is the base, 10) — always ~0 | `value / base^exponent`, a direct percentage |
| `Spindle/{n}/Load` | raw meter count, Int32 (`9155`) | ⚠️ Float64 % of rated load (`55.9`): the meter is a 1/16384 fraction of full scale |
| `Spindle/{n}/MaxRpm` | the type-1 meter read once at bootstrap and served as a "max RPM" | ⚠️ **renamed `MotorSpeedPct`**, Float64 % of maximum speed, polled every tick |
| Program `ONumber` / `Name` / `BlockCount` / `Mode` | defaulted to `0` / `""` when not reported (Mode `0` = MDI) | `BadNoData` |
Not ported (and why): the ignitionoee `RunState` run-word decode and fault debounce (this driver
publishes no run-state — `cnc_statinfo` is used only as the connectivity probe); `cnc_rdparam3`
(0x008D) and its echo guard (this driver never sends 0x008D — the real 31i-B answers it with
264-byte records that are not a parameter read); the IODBPSD-offset and Fanuc-real `FLOAT`
re-interpretation of `cnc_rdparam` (no successful `cnc_rdparam` capture exists on either side —
the reference 31i-B refuses it with `EW_FUNC`, which already reads `BadNotSupported` here); and
the all-axes multi-block `cnc_rddynamic2` batching (a latency optimisation, not a correctness fix).
## Further reading
@@ -132,12 +132,11 @@ public sealed class FocasAlarmProjectionOptions
/// default) to skip range validation and preserve legacy behaviour.
/// </param>
/// <param name="PositionDecimalPlaces">
/// Axis positions returned by <c>cnc_rddynamic2</c> are scaled integers. The driver
/// divides AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo by
/// <c>10^PositionDecimalPlaces</c> at the publish seam so they surface in engineering
/// units on the Float64 axis nodes. Default <c>0</c> (no scaling) is byte-identical to
/// legacy behaviour. Auto-fetching this via <c>cnc_getfigure</c> is deferred (wire-gated),
/// so it is config-supplied. Negative values are clamped to 0 (no scaling).
/// <b>Fallback only.</b> A real CNC's <c>cnc_rddynamic2</c> position block carries its own
/// scale (<c>raw / base^exponent</c>), which the driver always applies and which wins over
/// this setting. Only a block that carries no scale (a 4-byte payload, e.g. an older
/// simulator) is divided by <c>10^PositionDecimalPlaces</c>. Default <c>0</c> (no scaling).
/// Negative values are clamped to 0.
/// </param>
public sealed record FocasDeviceOptions(
string HostAddress,
@@ -41,11 +41,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// config redeploy).
/// </para>
/// <para>
/// Suffix collisions with genuine names are structurally impossible for axes (FOCAS axis
/// displays are at most two characters — name + suffix — so they cannot contain <c>_</c>),
/// but the generator still checks: a generated segment that would collide with anything
/// else in the set escalates its ordinal until unique, so the output is always
/// collision-free whatever the input.
/// Suffix collisions with genuine names are not expected (a FOCAS name record is at most four
/// ASCII characters and controls do not use <c>_</c>), but the generator still checks: a
/// generated segment that would collide with anything else in the set escalates its ordinal
/// until unique, so the output is always collision-free whatever the input.
/// </para>
/// <para>
/// <b>Note on the Makino that motivated this.</b> Its two "AA" axes were a decode artefact:
/// the name records actually read <c>"AA6"</c> and <c>"AA7"</c>, and the wire layer read only
/// the first two bytes of each 4-byte record. With the full record decoded
/// (<see cref="Wire.FocasWireProtocol.ReadNameRecord"/>) that machine reports unique names and
/// its axes publish bare (<c>Axes/AA6</c>, <c>Axes/AA7</c>). This disambiguation remains as the
/// guard for a control that genuinely reports duplicates.
/// </para>
/// </remarks>
internal static class FixedTreeNames
@@ -643,7 +643,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Spindle subtree — one folder per discovered spindle, suppressed
// entirely on series that don't export cnc_rdspdlname. Per-spindle
// Load + MaxRpm each gated on their own capability probe.
// Load + MotorSpeedPct (both cnc_rdspmeter percentages of full scale)
// each gated on their own capability probe.
if (cache.Capabilities.Spindles)
{
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
@@ -652,9 +653,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var name = cache.SpindleSegments[i];
var spindleFolder = spindleRoot.Folder(name, name);
if (cache.Capabilities.SpindleLoad)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Float64);
if (cache.Capabilities.SpindleMotorSpeed)
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MotorSpeedPct", DriverDataType.Float64);
}
}
@@ -925,15 +926,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
// Per-axis decimal-place figures (cnc_getfigure), fetched once. Auto figures win
// over the manual PositionDecimalPlaces config at the publish seam; an empty list
// (or a failed/empty figure read) makes every axis fall back to the config knob.
// Defensive: a figure-read failure must NOT fault device init — default to empty.
state.PositionFigures = await SafeProbe(() => client.GetPositionFiguresAsync(ct), []);
// Optional-API probes — each returns empty / throws when unsupported.
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
var spindleMotorSpeeds = await SafeProbe(() => client.GetSpindleMotorSpeedsAsync(ct), []);
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
@@ -942,13 +937,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var caps = new FocasFixedTreeCapabilities(
Spindles: spindles.Count > 0,
SpindleLoad: spindleLoad.Count > 0,
SpindleMaxRpm: spindleMaxRpms.Count > 0,
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
ServoLoad: servoLoads.Count > 0,
ProgramInfo: programInfo is not null,
Timers: timer is not null);
state.FixedTreeCache = new FocasFixedTreeCache(
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
// so stamp liveness here as well as per tick, or a CNC whose axis poll never
// succeeds would serve a perfectly valid sysinfo as stale.
@@ -964,15 +958,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
// Prime the spindle-loads cache from bootstrap if supported — avoids a
// Prime the spindle-meter caches from bootstrap if supported — avoids a
// "tree is there but reads say BadNodeIdUnknown" window on startup.
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true } or { SpindleMotorSpeed: true })
{
try
{
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
await PollSpindleMetersAsync(state, client2, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -999,7 +992,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
// Keyed by the disambiguated segment, matching what DiscoverAsync emitted —
// two same-named axes must not share (and overwrite) one cache slot (#548).
PublishAxisSnapshot(state, cache.AxisSegments[i], snap, i); // i = 0-based axis index → PositionFigures lookup
PublishAxisSnapshot(state, cache.AxisSegments[i], snap);
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
}
@@ -1025,17 +1018,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.Options.HostAddress);
}
}
if (cache.Capabilities.SpindleLoad)
if (cache.Capabilities.SpindleLoad || cache.Capabilities.SpindleMotorSpeed)
{
try
{
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
await PollSpindleMetersAsync(state, client, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
/* transient */
_logger.LogDebug(ex, "FOCAS spindle-loads poll failed for {Host}",
_logger.LogDebug(ex, "FOCAS spindle-meter poll failed for {Host}",
state.Options.HostAddress);
}
}
@@ -1102,40 +1094,34 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
/// <see cref="TryReadFixedTree"/> and returns these cached values.
/// </summary>
private static void PublishAxisSnapshot(DeviceState state, string axisSegment, FocasDynamicSnapshot snap, int axisIndex)
private static void PublishAxisSnapshot(DeviceState state, string axisSegment, FocasDynamicSnapshot snap)
{
var host = state.Options.HostAddress;
// cnc_rddynamic2 returns positions as scaled integers; divide by 10^figures so they
// surface in engineering units on the Float64 axis nodes. The figure is per-axis:
// an auto cnc_getfigure figure WINS, and the configured PositionDecimalPlaces is the
// fallback when the CNC didn't report one for that axis (see AxisFactor). A figure of
// 0 yields factor 1.0 — i.e. the integer widened to double, byte-identical to legacy
// behaviour (12345 / 1.0 == 12345.0). The managed WireFocasClient fetches figures live
// via ReadPositionFiguresAsync; the config knob is the per-axis fallback only. FeedRate /
// Each cnc_rddynamic2 position block carries its OWN scale (raw / base^exponent — see
// FocasPosition), so positions surface in engineering units with no second read; the
// configured PositionDecimalPlaces applies only to a block that carried no scale. A block
// describing no usable position (vacant 0xFFFF marker, odd base, absurd exponent) is
// cached as NaN and read back as BadNoData for that node alone (see NoValue). FeedRate /
// SpindleSpeed (rate snapshot) and ServoLoad are NOT position-scaled (published elsewhere).
var factor = AxisFactor(state, axisIndex);
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition / factor;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/DistanceToGo")] = snap.DistanceToGo / factor;
var dp = state.Options.PositionDecimalPlaces;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/AbsolutePosition")] = snap.AbsolutePosition.ToEngineering(dp) ?? NoValue;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/MachinePosition")] = snap.MachinePosition.ToEngineering(dp) ?? NoValue;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/RelativePosition")] = snap.RelativePosition.ToEngineering(dp) ?? NoValue;
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axisSegment}/DistanceToGo")] = snap.DistanceToGo.ToEngineering(dp) ?? NoValue;
}
/// <summary>
/// Resolve the position-scale factor (10^figure) for a single axis. Auto
/// (<c>cnc_getfigure</c>) wins per-axis; manual <c>PositionDecimalPlaces</c> is the
/// fallback when the CNC didn't report a figure for that axis. Both clamp non-negative;
/// 0 ⇒ factor 1.0 (legacy byte-identical). The managed wire backend fetches figures live
/// via <c>ReadPositionFiguresAsync</c>; the fallback branch fires only when the CNC
/// reports no figure for a given axis (or the figure read returns empty).
/// Cache sentinel for "the CNC answered, but this field holds no usable value" (an
/// undescribable position / meter record). The read path turns it into
/// <see cref="FocasStatusMapper.BadNoData"/> for that node only — never a Good number.
/// </summary>
private static double AxisFactor(DeviceState state, int axisIndex)
{
var figures = state.PositionFigures;
var dp = axisIndex >= 0 && axisIndex < figures.Count && figures[axisIndex] >= 0
? figures[axisIndex]
: Math.Max(0, state.Options.PositionDecimalPlaces);
return dp > 0 ? Math.Pow(10, dp) : 1.0;
}
private const double NoValue = double.NaN;
/// <summary>Builds the snapshot for one cached double, mapping <see cref="NoValue"/> to BadNoData.</summary>
private static DataValueSnapshot CachedDouble(double value, uint status, DateTime sourceUtc, DateTime now) =>
double.IsNaN(value)
? new DataValueSnapshot(null, status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status, sourceUtc, now)
: new DataValueSnapshot(value, status, sourceUtc, now);
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
{
@@ -1160,7 +1146,27 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var segments = FixedTreeNames.Disambiguate(loads.Select(l => l.AxisName));
for (var i = 0; i < loads.Count; i++)
state.LastServoLoads[segments[i]] = loads[i].LoadPercent;
state.LastServoLoads[segments[i]] = loads[i].LoadPercent ?? NoValue;
}
/// <summary>
/// Read and cache both spindle meters (load, motor speed) — each a <c>cnc_rdspmeter</c>
/// percentage of full scale (16384 = 100%) — for every spindle slot, as capability allows.
/// A slot whose record describes no value is cached as <see cref="NoValue"/> (BadNoData).
/// </summary>
private static async Task PollSpindleMetersAsync(DeviceState state, IFocasClient client, CancellationToken ct)
{
var caps = state.FixedTreeCache?.Capabilities;
if (caps is { SpindleLoad: true })
{
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i] ?? NoValue;
}
if (caps is { SpindleMotorSpeed: true })
{
var speeds = await client.GetSpindleMotorSpeedsAsync(ct).ConfigureAwait(false);
for (var i = 0; i < speeds.Count; i++) state.LastSpindleMotorSpeeds[i] = speeds[i] ?? NoValue;
}
}
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
@@ -1241,7 +1247,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (state.LastFixedSnapshots.TryGetValue(
FixedTreeReference(state.Options.HostAddress, path), out var raw))
return new DataValueSnapshot(raw, status, sourceUtc, now);
return CachedDouble(raw, status, sourceUtc, now);
// Servo-load match: path shape is "Axes/{name}/ServoLoad"
var suffixFull = path;
@@ -1249,10 +1255,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
if (state.LastServoLoads.TryGetValue(axisName, out var load))
return new DataValueSnapshot(load, status, sourceUtc, now);
return CachedDouble(load, status, sourceUtc, now);
}
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MotorSpeedPct"
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
&& state.FixedTreeCache is { } spindleCache)
{
@@ -1271,14 +1277,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
if (idx >= 0)
{
object? value = field switch
var meters = field switch
{
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
"Load" => state.LastSpindleLoads,
"MotorSpeedPct" => state.LastSpindleMotorSpeeds,
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, status, sourceUtc, now);
if (meters is not null && meters.TryGetValue(idx, out var pct))
return CachedDouble(pct, status, sourceUtc, now);
}
}
}
@@ -1301,8 +1307,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
? FocasOpMode.ToText(pi.Mode) : null,
"OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode
? FocasOpMode.ToText(mode) : null,
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
@@ -1311,6 +1317,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
};
if (value is not null)
return new DataValueSnapshot(value, status, sourceUtc, now);
// The program bundle has been read, but the CNC did not report this field (a refused
// sub-read, or no O-number in the payload). That is Bad — never a fabricated 0, and
// not "no such node" either.
if (state.LastProgramInfo is not null
&& path is "Program/Name" or "Program/ONumber" or "Program/BlockCount"
or "OperationMode/Mode" or "OperationMode/ModeText")
return new DataValueSnapshot(
null,
status == FocasStatusMapper.Good ? FocasStatusMapper.BadNoData : status,
sourceUtc,
now);
}
return null;
}
@@ -1489,7 +1507,6 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
FocasSysInfo SysInfo,
IReadOnlyList<FocasAxisName> Axes,
IReadOnlyList<FocasSpindleName> Spindles,
IReadOnlyList<int> SpindleMaxRpms,
FocasFixedTreeCapabilities Capabilities)
{
/// <summary>
@@ -1515,14 +1532,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Per-device optional-API capability flags — which of the "this may or may not
/// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
/// node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
/// node suppression so a 16i that doesn't export <c>cnc_rdspmeter</c> simply
/// doesn't get a <c>Spindle/{name}/MotorSpeedPct</c> node (instead of surfacing
/// <c>BadDeviceFailure</c> on every read).
/// </summary>
internal sealed record FocasFixedTreeCapabilities(
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
bool SpindleLoad, // cnc_rdspmeter type 0 (load) bootstrap probe succeeded
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
@@ -1578,9 +1595,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public DateTime? LastFixedPollUtc { get; set; }
/// <summary>
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
/// scale applied at <see cref="PublishAxisSnapshot"/>; integer fields
/// (feed rate, spindle speed) widen to double on store.
/// positions can carry the engineering-unit scale applied at
/// <see cref="PublishAxisSnapshot"/>; integer fields (feed rate, spindle speed) widen
/// to double on store. <see cref="NoValue"/> (NaN) marks a field the CNC described as
/// holding no value.
/// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets or sets the last program information snapshot.</summary>
@@ -1591,15 +1609,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public ConcurrentDictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = new();
/// <summary>Gets the last servo load percentages by servo name.</summary>
public ConcurrentDictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the last spindle load percentages by spindle index.</summary>
public ConcurrentDictionary<int, int> LastSpindleLoads { get; } = new();
/// <summary>
/// Gets or sets the per-axis position decimal-place figures fetched once at init via
/// <c>cnc_getfigure</c> (parallel to the axis-name list; index = axis). An auto figure
/// for an axis WINS over the configured <c>PositionDecimalPlaces</c>; an empty list (or
/// a failed/empty figure read) makes every axis fall back to that config knob.
/// Gets the last spindle load percentages (% of rated load) by spindle index;
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
/// </summary>
public IReadOnlyList<int> PositionFigures { get; set; } = [];
public ConcurrentDictionary<int, double> LastSpindleLoads { get; } = new();
/// <summary>
/// Gets the last spindle motor-speed percentages (% of maximum speed) by spindle index;
/// <see cref="NoValue"/> for a slot the CNC described as holding none.
/// </summary>
public ConcurrentDictionary<int, double> LastSpindleMotorSpeeds { get; } = new();
/// <summary>Disposes the FOCAS client instance.</summary>
public void DisposeClient()
@@ -290,9 +290,9 @@ public static class FocasDriverFactoryExtensions
public string? Series { get; init; }
/// <summary>
/// Gets or sets the axis-position decimal places. <c>cnc_rddynamic2</c> returns
/// positions as scaled integers; the driver divides by <c>10^PositionDecimalPlaces</c>
/// so they surface in engineering units. Omitted / <c>0</c> = no scaling (legacy).
/// Gets or sets the fallback axis-position decimal places, applied only to a
/// <c>cnc_rddynamic2</c> position block that carries no scale of its own (a real CNC's
/// block does, and its scale wins). Omitted / <c>0</c> = no scaling.
/// </summary>
public int? PositionDecimalPlaces { get; init; }
}
@@ -19,6 +19,14 @@ public static class FocasStatusMapper
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// The CNC answered, but its answer describes no usable value — a vacant macro variable, a
/// position / meter record whose own scale envelope is undecodable, a field the control did
/// not report. Distinct from a comms fault (the device is fine) and never a fabricated
/// Good <c>0</c>.
/// </summary>
public const uint BadNoData = 0x809B0000u;
/// <summary>
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
@@ -140,9 +140,10 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.5 (servo meters) ----
/// <summary>
/// Read the servo-load meter percentages across all configured axes.
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
/// disconnected session or unsupported CNC.
/// Read the servo-load meter percentages across all configured axes. Each record's own
/// <c>value / base^exponent</c> envelope is applied (servo loads are direct percentages);
/// a record whose envelope describes no value carries a null <see cref="FocasServoLoad.LoadPercent"/>.
/// Empty list on a disconnected session or unsupported CNC.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The servo-load meter percentages across all configured axes.</returns>
@@ -151,36 +152,31 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.6 (spindle meters) ----
/// <summary>
/// Read per-spindle load percentages. Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
/// disconnected session or when the CNC doesn't support the call (older
/// series like 16i may return EW_FUNC).
/// Read per-spindle load as a percentage of rated load (<c>cnc_rdspmeter</c> type 0; the
/// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>; a slot whose record describes no
/// value is null. Empty list on a disconnected session or when the CNC doesn't support the
/// call (older series like 16i may return EW_FUNC).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle load percentages, indexed as per <see cref="GetSpindleNamesAsync"/>.</returns>
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
/// Read per-spindle motor speed as a percentage of maximum speed (<c>cnc_rdspmeter</c>
/// type 1, historically labelled <c>cnc_rdspmaxrpm</c>). A live value, not a max-RPM
/// constant. Index alignment and null semantics as per <see cref="GetSpindleLoadsAsync"/>.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-spindle maximum RPM values, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the per-axis position decimal-place figure via <c>cnc_getfigure</c>. The returned
/// list is parallel to <see cref="GetAxisNamesAsync"/> (index = axis). An EMPTY list means the
/// CNC/backend does not report figures — the driver then falls back to the configured
/// <c>PositionDecimalPlaces</c>. Values are clamped non-negative.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Per-axis position decimal-place figures; empty when the CNC/backend doesn't report them.</returns>
Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken);
/// <returns>Per-spindle motor-speed percentages, indexed as per <see cref="GetSpindleLoadsAsync"/>.</returns>
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
}
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
/// <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).
/// </summary>
public sealed record FocasServoLoad(string AxisName, double? LoadPercent);
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
public enum FocasTimerKind
@@ -238,22 +234,19 @@ public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix
}
}
/// <summary>
/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
/// divides by <c>10^DecimalPlaces</c> to get the decimal value. DecimalPlaces is
/// supplied by the caller — either via device config (<c>PositionDecimalPlaces</c>) or
/// the per-axis <see cref="IFocasClient.GetPositionFiguresAsync"/> (<c>cnc_getfigure</c>) path.
/// </summary>
/// <summary>
/// Program + operation-mode snapshot. Name is the currently-executing
/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
/// Mode is the numeric code from <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
/// <b>A null field means the CNC did not report it</b> (its sub-read was refused, or the
/// payload was too short to carry it); the driver publishes that node Bad. It is never
/// defaulted to <c>0</c>, which is a genuine O-number, block count, and mode (MDI).
/// </summary>
public sealed record FocasProgramInfo(
string Name,
int ONumber,
int BlockCount,
int Mode);
string? Name,
int? ONumber,
int? BlockCount,
int? Mode);
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
public static class FocasOpMode
@@ -277,6 +270,10 @@ public static class FocasOpMode
};
}
/// <summary>
/// Fast-poll bundle for one axis (<c>cnc_rddynamic2</c>). Each position carries its own scale
/// (see <see cref="FocasPosition"/>); the driver converts it to engineering units at publish.
/// </summary>
public sealed record FocasDynamicSnapshot(
int AxisIndex,
int AlarmFlags,
@@ -285,10 +282,60 @@ public sealed record FocasDynamicSnapshot(
int SequenceNumber,
int ActualFeedRate,
int ActualSpindleSpeed,
int AbsolutePosition,
int MachinePosition,
int RelativePosition,
int DistanceToGo);
FocasPosition AbsolutePosition,
FocasPosition MachinePosition,
FocasPosition RelativePosition,
FocasPosition DistanceToGo);
/// <summary>
/// One <c>cnc_rddynamic2</c> position (command <c>0x0026</c>) as the CNC reported it. The 8-byte
/// wire block is self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c> (2 or 10),
/// <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = the slot holds no valid position) — and the real
/// coordinate is <c>raw / base^exponent</c>. Confirmed on a real FANUC 31i-B: every block of the
/// <c>ref-dynamic2-axis1.bin</c> capture reports base 10 / exponent 4 (280.1574 mm, not
/// 2801574). That envelope is why no separate figure read exists: the <c>cnc_getfigure</c>
/// (0x00D3) this driver used to send is not a FOCAS wire command, and the 31i-B refuses it.
/// </summary>
/// <param name="Raw">The raw i32 position value.</param>
/// <param name="Base">The block's scale base (only 2 and 10 are meaningful).</param>
/// <param name="Exponent">The block's scale exponent.</param>
/// <param name="HasScale">
/// True when the block carried the base/exponent envelope. A scale-less block (a 4-byte payload
/// from an older simulator, or a raw integer a test supplies) falls back to the device's
/// configured <c>PositionDecimalPlaces</c>.
/// </param>
public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
{
/// <summary>
/// False when the block says it holds no usable position: the <c>0xFFFF</c> vacant marker, a
/// base other than 2 or 10, or an absurd exponent. A per-node quality decision — that node
/// publishes Bad, its siblings are unaffected.
/// </summary>
/// <remarks>
/// The exponent bound matters: left unbounded, <c>raw / 10^400</c> is
/// <c>raw / Infinity = 0.0</c> — a Good reading of a machine parked at the origin.
/// </remarks>
public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>
/// The position in engineering units: <c>Raw / Base^Exponent</c> when the block carried its
/// own scale (the configured fallback is then ignored), else <c>Raw / 10^fallbackDecimalPlaces</c>.
/// Null when <see cref="IsValid"/> is false.
/// </summary>
/// <param name="fallbackDecimalPlaces">The device's configured <c>PositionDecimalPlaces</c>.</param>
/// <returns>The scaled position, or null when the block describes none.</returns>
public double? ToEngineering(int fallbackDecimalPlaces)
{
if (!IsValid) return null;
if (HasScale) return Wire.FocasValueEnvelope.Scale(Raw, Base, Exponent);
var dp = Math.Max(0, fallbackDecimalPlaces);
return dp > 0 ? Raw / Math.Pow(10, dp) : Raw;
}
/// <summary>A bare integer is a scale-less position (legacy shape; tests and fakes).</summary>
/// <param name="raw">The raw position value.</param>
public static implicit operator FocasPosition(int raw) => new(raw);
}
/// <summary>
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
@@ -99,16 +99,12 @@ public sealed class SynchronizedFocasClient : IFocasClient
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(ct), cancellationToken);
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
/// <summary>Disposes the inner client and the call gate.</summary>
public void Dispose()
@@ -339,21 +339,59 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult<WireDynamic>(rc, null);
return new FocasResult<WireDynamic>(rc, ParseDynamic2(blocks));
}
/// <summary>
/// Aggregate the <c>cnc_rddynamic2</c> response blocks of one axis read into a
/// <see cref="WireDynamic"/>. The four <c>0x0026</c> blocks are taken in request order
/// (absolute / machine / relative / distance-to-go), each decoded with its own scale by
/// <see cref="ParsePositionBlock"/>.
/// </summary>
/// <param name="blocks">The response blocks of one <see cref="ReadDynamic2Async"/> request.</param>
/// <returns>The decoded fast-poll bundle.</returns>
internal static WireDynamic ParseDynamic2(IReadOnlyList<ResponseBlock> blocks)
{
var programPayload = FindPayload(blocks, 0x001c);
return new FocasResult<WireDynamic>(
rc,
new WireDynamic(
ReadFirstInt32(blocks, 0x001a),
programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
ReadFirstInt32(blocks, 0x001d),
ReadFirstInt32(blocks, 0x0024),
ReadFirstInt32(blocks, 0x0025),
new WireAxisPosition(
ReadSelectorPosition(blocks, 0x0026, 0),
ReadSelectorPosition(blocks, 0x0026, 1),
ReadSelectorPosition(blocks, 0x0026, 2),
ReadSelectorPosition(blocks, 0x0026, 3))));
return new WireDynamic(
ReadFirstInt32(blocks, 0x001a),
programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
ReadFirstInt32(blocks, 0x001d),
ReadFirstInt32(blocks, 0x0024),
ReadFirstInt32(blocks, 0x0025),
new WireAxisPosition(
ReadSelectorPosition(blocks, 0x0026, 0),
ReadSelectorPosition(blocks, 0x0026, 1),
ReadSelectorPosition(blocks, 0x0026, 2),
ReadSelectorPosition(blocks, 0x0026, 3)));
}
/// <summary>
/// Decode one <c>cnc_rddynamic2</c> position block (0x0026). The 8-byte block is
/// self-describing — <c>[0..3] i32 raw</c>, <c>[4..5] u16 base</c>, <c>[6..7] u16 exponent</c>,
/// value = <c>raw / base^exponent</c> — pinned on a real FANUC 31i-B (every block of
/// <c>ref-dynamic2-axis1.bin</c> reports base 10 / exponent 4). A 4..7-byte block carries no
/// envelope and is returned scale-less (the driver then applies the configured
/// <c>PositionDecimalPlaces</c>); a shorter one decodes as a scale-less 0, as before.
/// </summary>
/// <remarks>
/// Validity (vacant <c>0xFFFF</c> exponent, a base other than 2/10, an absurd exponent) is a
/// per-node quality decision made by <see cref="FocasPosition.IsValid"/>, not a fault here:
/// raising would let one empty slot take out the whole poll. Ported from the sister
/// ignitionoee driver v0.17.0 (<c>Dynamic2.scaledPosition</c>, Focas.Wire-014).
/// </remarks>
/// <param name="payload">The 0x0026 response-block payload.</param>
/// <returns>The position with whatever scale the block carried.</returns>
internal static FocasPosition ParsePositionBlock(byte[] payload)
{
if (payload.Length >= 8)
return new FocasPosition(
ReadInt32(payload, 0),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2)),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2)),
HasScale: true);
return new FocasPosition(payload.Length >= 4 ? ReadInt32(payload, 0) : 0);
}
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
@@ -383,47 +421,14 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
}
/// <summary>
/// Read the per-axis position decimal-place figures via <c>cnc_getfigure</c>
/// (command <c>0x00d3</c>). The response payload is a self-delimiting sequence of
/// big-endian <c>short</c> decimal-place counts — one per configured axis — which the
/// driver applies as a <c>10^figure</c> scale on raw position reads.
/// Read the per-spindle load meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>, type 0).
/// <see cref="WireSpindleMetric.Percent"/> is % of rated load (16384 = 100%).
/// </summary>
/// <remarks>
/// The <c>0x00d3</c> command id is co-designed with the in-tree <c>focas_mock</c> sim and
/// validated against it; like the rest of this managed wire backend the binary shape is
/// sim-consistent and has not been validated against a real Fanuc CNC (bench-CNC-gated).
/// </remarks>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The per-axis position decimal-place figures, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public async Task<FocasResult<IReadOnlyList<int>>> ReadPositionFiguresAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
var requestPathId = EffectivePathId(pathId);
var block = await SendSingleRequestAsync(
callTimeout.Token,
new RequestBlock(0x00d3, PathId: requestPathId)).ConfigureAwait(false);
if (block.Rc != 0) return new FocasResult<IReadOnlyList<int>>(block.Rc, null);
var payload = block.Payload;
var figures = new List<int>(payload.Length / 2);
for (var offset = 0; offset + 2 <= payload.Length; offset += 2)
figures.Add(ReadInt16(payload, offset));
return new FocasResult<IReadOnlyList<int>>(block.Rc, figures);
}
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The per-spindle load percentages, wrapped in a <see cref="FocasResult{T}"/>.</returns>
/// <returns>The per-spindle load meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
@@ -431,13 +436,19 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
ushort? pathId = null)
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
/// <summary>
/// Read the per-spindle motor-speed meter via <c>cnc_rdspmeter</c> (command <c>0x0040</c>,
/// type 1 — historically labelled <c>cnc_rdspmaxrpm</c>). It is NOT a maximum-RPM constant:
/// it is the live motor speed as a fraction of maximum, <see cref="WireSpindleMetric.Percent"/>
/// (a regression against <c>cnc_rddynamic2</c> spindle speed on a real 31i-B gives
/// <c>rpm = 1.220749 × meter</c>, i.e. 16384 = the spindle's 20,000 rpm maximum).
/// </summary>
/// <param name="spindleSelector">Spindle selector; -1 selects all spindles.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <param name="timeout">Optional per-call timeout override.</param>
/// <param name="pathId">Optional path ID override; defaults to <see cref="PathId"/>.</param>
/// <returns>The per-spindle maximum RPMs, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
/// <returns>The per-spindle motor-speed meters, wrapped in a <see cref="FocasResult{T}"/>.</returns>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMotorSpeedAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
@@ -577,9 +588,33 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0015,
payload => new WireMacro(number, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 6 ? ReadInt16(payload, 4) : (short)0),
payload => ParseMacro(number, payload),
cancellationToken, timeout, EffectivePathId(pathId), number, number);
/// <summary>
/// Decode a <c>cnc_rdmacro</c> (0x0015) payload. The on-wire <c>ODBM</c> record is 8
/// big-endian bytes: <c>[0..3] i32 mantissa</c>, <c>[4..5] i16</c> length field (a
/// <b>constant 10</b> on every observed response), <c>[6..7] i16 dec_val</c>
/// (<c>0xFFFF</c> = vacant).
/// </summary>
/// <remarks>
/// The middle field is the trap: it is not the decimal count but looks like a plausible one,
/// so reading the decimal at offset 4 decoded every macro on every control without ever
/// failing — just wrongly (the captured <c>#500 = 1.0</c> published <c>0.01</c>). Pinned by
/// a live Makino 31i-B capture; found and fixed by the sister ignitionoee driver
/// (Focas.Wire-013, 2026-08-05). A payload shorter than 8 bytes carries no
/// <c>dec_val</c> and is refused rather than guessed at.
/// </remarks>
/// <param name="number">The macro number the request asked for.</param>
/// <param name="payload">The response-block payload.</param>
/// <returns>The decoded macro.</returns>
/// <exception cref="FocasWireException">The payload is shorter than 8 bytes.</exception>
internal static WireMacro ParseMacro(short number, byte[] payload)
{
RequireLength(payload, 8, "cnc_rdmacro");
return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
}
/// <summary>
/// Read a PMC range via <c>pmc_rdpmcrng</c>. <paramref name="area"/> is the numeric
/// address-letter code (see <see cref="FocasPmcArea"/>); <paramref name="dataType"/>
@@ -745,13 +780,27 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
callTimeout.Token,
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult<IReadOnlyList<WireSpindleMetric>>(block, payload =>
{
var values = new List<WireSpindleMetric>();
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset)));
return values;
});
return ToResult(block, ParseSpindleMetrics);
}
/// <summary>
/// Decode a <c>cnc_rdspmeter</c> (0x0040) payload: 8-byte records of
/// <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c>, one per
/// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode
/// as <see cref="WireSpindleMetric.IsValid"/> = false). Every slot is returned, in wire order.
/// </summary>
/// <param name="payload">The response-block payload.</param>
/// <returns>The per-slot meters.</returns>
internal static IReadOnlyList<WireSpindleMetric> ParseSpindleMetrics(byte[] payload)
{
var values = new List<WireSpindleMetric>();
for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
values.Add(new WireSpindleMetric(
(short)(values.Count + 1),
ReadInt32(payload, offset),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 4, 2)),
BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 6, 2))));
return values;
}
private async Task<FocasResult<T>> ReadSingleAsync<T>(
@@ -932,18 +981,16 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
/// <summary>
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. On the
/// 31i-B (v3) each per-axis LOADELM is <b>8 bytes</b> — {int32 data; int16 dec; int16 unit}
/// — NOT the 12-byte shape the original parser assumed. The 12-byte stride misaligned after
/// the first record (it read the dec/unit shorts of the prior record as the next record's
/// data → wild values like 655360). Axis NAMES are not in the svmeter payload; they come
/// from the <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated
/// by index. Validated against a live 31i-B 2026-06-25.
/// <para><b>Scaling caveat:</b> downstream applies LoadPercent = data / 10^dec; on the 31i-B
/// <c>dec</c> read as 10, which makes idle loads vanishingly small. The data/dec/unit field
/// semantics for servo load are inferred from the wire and not yet confirmed against the
/// machine's servo-meter screen — confirm magnitude at commissioning. The alignment + name
/// fix here is what removes the gross misaligned garbage.</para>
/// Decode a <c>cnc_rdsvmeter</c> response into <see cref="WireServoMeter"/> records. Each
/// per-axis record is <b>8 bytes</b> — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>,
/// <c>[+6..7] u16 exponent</c> — the same self-describing envelope as the position blocks,
/// so the load is <c>value / base^exponent</c>, a direct percentage. (A 12-byte stride
/// misaligned after the first record; reading offset 4 as a decimal count divided every load
/// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee
/// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the
/// <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated by index.
/// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue
/// and decode as <see cref="WireServoMeter.IsValid"/> = false.
/// </summary>
/// <param name="svPayload">The raw <c>cnc_rdsvmeter</c> response payload.</param>
/// <param name="axisNamePayload">The raw <c>cnc_rdaxisname</c> response payload used to correlate axis names by index.</param>
@@ -963,8 +1010,8 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
(short)(index + 1),
name,
ReadInt32(svPayload, offset),
ReadInt16(svPayload, offset + 4),
ReadInt16(svPayload, offset + 6)));
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 4, 2)), // BASE, not a decimal
BinaryPrimitives.ReadUInt16BigEndian(svPayload.AsSpan(offset + 6, 2))));
}
return result;
}
@@ -1020,17 +1067,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
}
private static int ReadSelectorPosition(IReadOnlyList<ResponseBlock> blocks, ushort command, int selectorIndex)
private static FocasPosition ReadSelectorPosition(IReadOnlyList<ResponseBlock> blocks, ushort command, int selectorIndex)
{
var seen = 0;
foreach (var block in blocks)
{
if (block.Command != command) continue;
if (seen == selectorIndex)
return block.Payload.Length >= 4 ? ReadInt32(block.Payload, 0) : 0;
return ParsePositionBlock(block.Payload);
seen++;
}
return 0;
return new FocasPosition(0);
}
private static WireSysInfo ParseSysInfo(byte[] payload)
@@ -1090,7 +1137,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
return alarms;
}
private static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
/// <summary>
/// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records,
/// each named by <see cref="FocasWireProtocol.ReadNameRecord"/>. Blank records are skipped but
/// keep their slot's 1-based index.
/// </summary>
/// <typeparam name="T">The record type the factory builds.</typeparam>
/// <param name="payload">The response-block payload.</param>
/// <param name="maxCount">Maximum number of records to inspect.</param>
/// <param name="factory">Builds one record from its 1-based index and name.</param>
/// <returns>The non-blank records, in wire order.</returns>
internal static IReadOnlyList<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
{
var names = new List<T>();
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
@@ -35,12 +35,15 @@ public sealed record WireStatus(
short Edit,
short TmMode);
/// <summary>Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.</summary>
/// <summary>
/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go,
/// each carrying the scale its own <c>0x0026</c> block reported.
/// </summary>
public sealed record WireAxisPosition(
int Absolute,
int Machine,
int Relative,
int Distance);
FocasPosition Absolute,
FocasPosition Machine,
FocasPosition Relative,
FocasPosition Distance);
/// <summary>
/// Fast-poll bundle for one axis from <c>cnc_rddynamic2</c> — alarm flags, active program
@@ -56,18 +59,104 @@ public sealed record WireDynamic(
int SpindleSpeed,
WireAxisPosition Axis);
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
/// <summary>
/// The self-describing <c>(value, base, exponent)</c> envelope shared by every value record on
/// the FOCAS wire — <c>cnc_rddynamic2</c> positions, servo meters, spindle meters:
/// <c>scaled = value / base^exponent</c>, where only base 2 and base 10 are meaningful.
/// </summary>
internal static class FocasValueEnvelope
{
/// <summary>
/// Largest exponent that can describe a real value. An i32 carries at most ten digits, so
/// anything beyond is a misread record — and <c>value / 10^400</c> would be
/// <c>value / Infinity = 0.0</c>, a plausible Good zero.
/// </summary>
public const int MaxExponent = 18;
/// <summary>
/// True when the record describes a usable value. A fixed multi-slot buffer's slots past the
/// configured axis/spindle count hold unrelated memory, which shows up exactly here: a base
/// that is neither 2 nor 10, or an absurd exponent (incl. the <c>0xFFFF</c> vacant marker).
/// </summary>
/// <param name="base">The record's base field.</param>
/// <param name="exponent">The record's exponent field.</param>
/// <returns>Whether the record may be published.</returns>
public static bool IsValid(int @base, int exponent) => exponent <= MaxExponent && @base is 2 or 10;
/// <summary><c>value / base^exponent</c>, or null when <see cref="IsValid"/> is false.</summary>
/// <param name="value">The raw value.</param>
/// <param name="base">The record's base field.</param>
/// <param name="exponent">The record's exponent field.</param>
/// <returns>The scaled value, or null.</returns>
public static double? Scale(int value, int @base, int exponent) =>
!IsValid(@base, exponent) ? null : exponent == 0 ? value : value / Math.Pow(@base, exponent);
}
/// <summary>
/// One servo-meter entry from <c>cnc_rdsvmeter</c> (0x0056): one axis's load. The 8-byte record
/// is <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c>, <c>[+6..7] u16 exponent</c> and the load
/// is a <b>direct</b> percentage, <see cref="Scaled"/> = <c>value / base^exponent</c>.
/// </summary>
/// <remarks>
/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the <b>base</b> — a
/// steady 10 — so every load was divided by 10^10 and published ~0 (a real Makino cutting at
/// 20,000 rpm read servo 0% on every axis). Found by the sister ignitionoee driver,
/// Focas.Wire-019 (v0.17.2).
/// </remarks>
/// <param name="Index">1-based axis slot.</param>
/// <param name="Name">Axis name from the companion 0x0089 block (empty when none).</param>
/// <param name="Value">Raw load value.</param>
/// <param name="Base">Scale base (2 or 10 when meaningful).</param>
/// <param name="Exponent">Scale exponent.</param>
public sealed record WireServoMeter(
short Index,
string Name,
int Value,
short Decimal,
short Unit);
int Base,
int Exponent)
{
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
/// <summary>The load percentage, <c>Value / Base^Exponent</c>; null when <see cref="IsValid"/> is false.</summary>
public double? Scaled => FocasValueEnvelope.Scale(Value, Base, Exponent);
}
/// <summary>
/// One spindle-meter slot from <c>cnc_rdspmeter</c> (0x0040; type 0 = load, type 1 = motor
/// speed). Same 8-byte envelope as <see cref="WireServoMeter"/>, but the scaled value is a
/// fraction of full scale in <c>1/16384</c> units — see <see cref="Percent"/>.
/// </summary>
/// <param name="Index">1-based spindle slot.</param>
/// <param name="Value">Raw meter value, in 1/16384-of-full-scale units.</param>
/// <param name="Base">Scale base.</param>
/// <param name="Exponent">Scale exponent.</param>
public sealed record WireSpindleMetric(
short Index,
int Value);
int Value,
int Base,
int Exponent)
{
/// <summary>
/// The meter's full-scale count: <c>16384</c> = 2^14 = 100% of rated load (type 0) or of
/// maximum spindle speed (type 1). Settled on the real Z-34184 by regressing the type-1
/// meter against <c>cnc_rddynamic2</c> spindle speed over 596–20,000 rpm:
/// <c>rpm = 1.220749 × meter</c>, <c>16384 × 1.220749 = 20000.75</c> (Focas.Wire-019).
/// </summary>
public const int FullScale = 1 << 14;
/// <summary>False for a record whose envelope describes no usable value (buffer residue).</summary>
public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
/// <summary>
/// The meter as a percentage — <c>(Value / Base^Exponent) × 100 / 16384</c> — or null when
/// <see cref="IsValid"/> is false. Values above 100 are real (short-term overload; the i16
/// record clamps one count short of 200%).
/// </summary>
public double? Percent => FocasValueEnvelope.Scale(Value, Base, Exponent) is { } scaled
? scaled * 100.0 / FullScale
: null;
}
/// <summary>
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
@@ -86,13 +175,31 @@ public sealed record WireParameter(
int Value);
/// <summary>
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the callable value is
/// <c>Value / 10^Decimal</c>.
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the real value is
/// <c>Value / 10^Decimal</c> (<see cref="Scaled"/>). FANUC normalises every macro to a
/// 9-significant-digit mantissa, so <see cref="Value"/> alone is never a value to publish
/// (live: <c>#502 = 270</c> arrives as <c>270000000 / 10^6</c>).
/// </summary>
/// <param name="Number">Macro variable number (the request argument; not carried in the payload).</param>
/// <param name="Value">The 9-significant-digit mantissa.</param>
/// <param name="Decimal">Implied decimal places; negative (<c>0xFFFF</c> on the wire) = vacant.</param>
public sealed record WireMacro(
short Number,
int Value,
short Decimal);
short Decimal)
{
/// <summary>
/// True when the CNC reported the variable as vacant (undefined / never set):
/// <c>dec_val == 0xFFFF</c>. A vacant macro's mantissa is 0, indistinguishable from a
/// genuine 0, so this must be tested before <see cref="Value"/> means anything. Any
/// negative decimal is treated as vacant — no other negative has ever been observed and
/// failing closed is the safe reading.
/// </summary>
public bool IsVacant => Decimal < 0;
/// <summary>The real value, <c>Value / 10^Decimal</c>; null when <see cref="IsVacant"/>.</summary>
public double? Scaled => IsVacant ? null : Decimal == 0 ? Value : Value / Math.Pow(10.0, Decimal);
}
/// <summary>PMC range read-back from <c>pmc_rdpmcrng</c>: one or more values of the requested width.</summary>
public sealed record WirePmcRange(
@@ -239,19 +239,34 @@ internal static class FocasWireProtocol
}
/// <summary>
/// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte
/// (spindle) slot. Trailing spaces and NULs are stripped so <c>"X "</c> becomes
/// <c>"X"</c>.
/// Read one 4-byte axis/spindle name record: the ASCII up to (not including) the record's
/// FIRST NUL, at most 4 bytes, with trailing spaces trimmed — <c>"X \0\0"</c> → <c>"X"</c>,
/// <c>"AA6\0"</c> → <c>"AA6"</c>, <c>"S1\0N"</c> → <c>"S1"</c>.
/// </summary>
/// <param name="bytes">The bytes to decode.</param>
/// <returns>The decoded name record.</returns>
/// <remarks>
/// <para><b>All four bytes are name bytes.</b> This used to read only the first two, which
/// collapsed the real 31i-B's paired axes <c>"AA6"</c>/<c>"AA7"</c> (live capture
/// <c>ref-axisname-0x0089.bin</c>) to two axes both called <c>"AA"</c> — the duplicate Gitea
/// #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
/// <para><b>The first NUL terminates, not trailing-NUL trimming.</b> A control writes only the
/// bytes its name needs and leaves the rest of the buffer as it found it: Makino Z-34184's
/// <c>cnc_rdspdlname</c> answered <c>53 31 00 4e</c> — <c>"S1"</c> plus the <c>'N'</c> of an
/// earlier <c>"CNC"</c> response. Both rules are ported from the sister ignitionoee FOCAS
/// driver (Focas.Wire-001 / Focas.Wire-022).</para>
/// </remarks>
/// <param name="bytes">The record bytes (4 on the wire; a shorter span is read as far as it goes).</param>
/// <returns>The decoded name; empty for a blank record.</returns>
public static string ReadNameRecord(ReadOnlySpan<byte> bytes)
{
if (bytes.Length < 2) return string.Empty;
var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray();
return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0');
var record = bytes.Slice(0, Math.Min(NameRecordLength, bytes.Length));
var nul = record.IndexOf((byte)0);
if (nul >= 0) record = record.Slice(0, nul);
return Encoding.ASCII.GetString(record).TrimEnd(' ');
}
/// <summary>Byte length of one axis/spindle name record.</summary>
public const int NameRecordLength = 4;
private static byte[] BuildRequestBlock(RequestBlock request)
{
var extra = request.ExtraPayload ?? Array.Empty<byte>();
@@ -62,7 +62,7 @@ public sealed class WireFocasClient : IFocasClient
{
FocasAreaKind.Pmc => await ReadPmcAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Parameter => await ReadParameterAsync(address, type, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Macro => await ReadMacroAsync(address, cancellationToken).ConfigureAwait(false),
FocasAreaKind.Macro => await ReadMacroAsync(address, type, cancellationToken).ConfigureAwait(false),
_ => (null, FocasStatusMapper.BadNotSupported),
};
}
@@ -181,6 +181,8 @@ public sealed class WireFocasClient : IFocasClient
ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
var d = result.Value!;
var pos = d.Axis ?? new WireAxisPosition(0, 0, 0, 0);
// Positions keep the scale their own 0x0026 block reported; the driver converts them to
// engineering units at publish (FocasPosition.ToEngineering).
return new FocasDynamicSnapshot(
AxisIndex: axisIndex,
AlarmFlags: d.Alarm,
@@ -215,12 +217,29 @@ public sealed class WireFocasClient : IFocasClient
throw new InvalidOperationException(
$"cnc_exeprgname2 failed EW_{nameResult.Rc} and cnc_rdopmode failed EW_{modeResult.Rc}.");
var wireName = nameResult.Value;
return MapProgramInfo(nameResult, blkResult, modeResult);
}
/// <summary>
/// Combine the three program sub-reads into a <see cref="FocasProgramInfo"/>, leaving any
/// field the CNC did not report null. A refused read, or an <c>cnc_exeprgname2</c> payload
/// too short to carry the trailing O-number, used to publish a fabricated <c>0</c> at Good
/// quality — indistinguishable from a real 0 (a 31i reports O-number 0 for a program run
/// by path) and, for the mode, from MDI. Ported from the sister ignitionoee driver (101a3b1).
/// </summary>
/// <param name="name">The <c>cnc_exeprgname2</c> result.</param>
/// <param name="blockCount">The <c>cnc_rdblkcount</c> result.</param>
/// <param name="mode">The <c>cnc_rdopmode</c> result.</param>
/// <returns>The snapshot, null where the CNC did not say.</returns>
internal static FocasProgramInfo MapProgramInfo(
FocasResult<WireProgramName> name, FocasResult<int> blockCount, FocasResult<short> mode)
{
var wireName = name.IsOk ? name.Value : null;
return new FocasProgramInfo(
Name: wireName?.Name ?? string.Empty,
ONumber: wireName?.ONumber ?? 0,
BlockCount: blkResult.IsOk ? blkResult.Value : 0,
Mode: modeResult.IsOk ? modeResult.Value : 0);
Name: wireName?.Name,
ONumber: wireName?.ONumber,
BlockCount: blockCount.IsOk ? blockCount.Value : null,
Mode: mode.IsOk ? mode.Value : null);
}
/// <inheritdoc />
@@ -240,52 +259,32 @@ public sealed class WireFocasClient : IFocasClient
var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
return result.Value
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
.Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Scaled))
.Where(s => s.AxisName.Length > 0)
.ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken);
/// <inheritdoc />
// Reads via FocasWireClient.ReadPositionFiguresAsync; returns an empty list when the read
// fails (non-zero RC — e.g. the series lacks cnc_getfigure) or is not connected. Never
// throws — figure reads degrade to the configured PositionDecimalPlaces rather than faulting.
public async Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken cancellationToken)
{
if (!_wire.IsConnected) return [];
try
{
var result = await _wire.ReadPositionFiguresAsync(cancellationToken).ConfigureAwait(false);
return result.IsOk && result.Value is { } figures ? figures : [];
}
catch (FocasWireException)
{
return [];
}
}
private static async Task<IReadOnlyList<int>> ReadSpindleMetricAsync(
/// <summary>
/// One <c>cnc_rdspmeter</c> read as per-slot percentages (null for an undescribable slot).
/// Every slot is returned — the driver reads only the slots of discovered spindles. (This
/// used to stop at the first zero after slot 1, which both dropped a genuinely idle second
/// spindle and kept non-zero buffer residue.)
/// </summary>
private static async Task<IReadOnlyList<double?>> ReadSpindleMetricAsync(
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
CancellationToken cancellationToken)
{
var result = await call(-1, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
var list = new List<int>();
foreach (var m in result.Value)
{
// Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the
// list length matches the configured spindle count.
if (m.Value == 0 && list.Count > 0) break;
list.Add(m.Value);
}
return list;
return result.Value.Select(m => m.Percent).ToList();
}
/// <summary>Disposes the wire client and releases all resources.</summary>
@@ -368,17 +367,14 @@ public sealed class WireFocasClient : IFocasClient
}
private async Task<(object? value, uint status)> ReadMacroAsync(
FocasAddress address, CancellationToken cancellationToken)
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
try
{
var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var m = result.Value;
// Macro value is scaled-decimal: the real value is Value / 10^Decimal.
var scaled = m.Value / Math.Pow(10.0, m.Decimal);
return ((object)scaled, FocasStatusMapper.Good);
return MapMacro(result.Value, type, address.BitIndex);
}
catch (FocasWireException ex)
{
@@ -386,6 +382,62 @@ public sealed class WireFocasClient : IFocasClient
}
}
/// <summary>
/// Turn a decoded macro into the value + quality its tag publishes.
/// </summary>
/// <remarks>
/// <para>A <b>vacant</b> variable has no value at all → <see cref="FocasStatusMapper.BadNoData"/>,
/// never a Good <c>0</c> (its mantissa is 0, indistinguishable from a genuine zero).</para>
/// <para>Float tags get <c>mantissa / 10^decimal</c>. <b>Integer</b> tags (Byte / Int16 /
/// Int32 / Bit) get the exact whole value, tested with integer arithmetic
/// (<c>mantissa % 10^decimal</c>) so no epsilon is involved; a fractional macro on an
/// integer tag is <see cref="FocasStatusMapper.BadTypeMismatch"/> — truncating or rounding
/// would publish a plausible wrong count at Good quality — and a whole value that does not
/// fit the type is <see cref="FocasStatusMapper.BadOutOfRange"/>. Ported from the sister
/// ignitionoee driver's <c>macroInteger</c>/<c>macroReal</c> (Focas-017/018).</para>
/// </remarks>
/// <param name="m">The decoded macro.</param>
/// <param name="type">The tag's declared data type.</param>
/// <param name="bitIndex">Optional bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
/// <returns>The boxed value (null when Bad) and its status.</returns>
internal static (object? value, uint status) MapMacro(WireMacro m, FocasDataType type, int? bitIndex)
{
if (m.Scaled is not { } scaled) return (null, FocasStatusMapper.BadNoData);
switch (type)
{
case FocasDataType.Float64:
return (scaled, FocasStatusMapper.Good);
case FocasDataType.Float32:
return ((float)scaled, FocasStatusMapper.Good);
case FocasDataType.Byte or FocasDataType.Int16 or FocasDataType.Int32 or FocasDataType.Bit:
if (!TryWholeMacroValue(m, out var whole)) return (null, FocasStatusMapper.BadTypeMismatch);
return type switch
{
FocasDataType.Bit => (bitIndex is int bit ? ((whole >> bit) & 1L) != 0 : whole != 0, FocasStatusMapper.Good),
FocasDataType.Byte when whole is >= sbyte.MinValue and <= sbyte.MaxValue => ((sbyte)whole, FocasStatusMapper.Good),
FocasDataType.Int16 when whole is >= short.MinValue and <= short.MaxValue => ((short)whole, FocasStatusMapper.Good),
FocasDataType.Int32 when whole is >= int.MinValue and <= int.MaxValue => ((int)whole, FocasStatusMapper.Good),
_ => (null, FocasStatusMapper.BadOutOfRange),
};
default:
return (scaled, FocasStatusMapper.Good);
}
}
/// <summary>Exact <c>mantissa / 10^decimal</c> when it is a whole number; integer arithmetic only.</summary>
private static bool TryWholeMacroValue(WireMacro m, out long whole)
{
whole = 0;
if (m.Value == 0) return true; // 0 is whole at any decimal
if (m.Decimal > 18) return false; // an i32 mantissa over 10^19+ is always fractional
var divisor = 1L;
for (var i = 0; i < m.Decimal; i++) divisor *= 10L;
if (m.Value % divisor != 0) return false;
whole = m.Value / divisor;
return true;
}
private void RequireConnected()
{
if (!_wire.IsConnected)
@@ -43,3 +43,18 @@ Copy-Item "$src\Dockerfile" "$dest\"
Update the snapshot date at the top of this file afterward. No other
files belong here — the Docker build context is just the Python package
and its metadata.
## Local divergence (do not lose on refresh)
`src/focas_mock/server.py` and `defaults.py` carry OtOpcUa-local edits that make the wire
shapes match a real FANUC 31i-B (Makino Z-34184) instead of shapes the driver and the mock had
agreed on between themselves. A refresh that overwrites `src/` must re-apply them:
- the little-endian `cnc_rdtimer` minute/msec encoding (2026-06-25, live-validated);
- the alarm count cap on `cnc_rdalmmsg2`;
- (2026-10-07, ported from the sister ignitionoee FOCAS mock) `0x00D3` ("cnc_getfigure", not a
real FOCAS wire command) refused with `EW_FUNC`; `0x0026` position blocks carrying their own
`base/exponent` (`position_figures` is now that exponent) and a signed `arg2`;
`cnc_rdmacro` as the 8-byte ODBM shape (`dec_val` at `[6..7]`, 9-digit mantissa, unset
macro = vacant); servo/spindle meter records with the `base/exponent` envelope, spindle
meters in 1/16384-of-full-scale counts.
@@ -78,9 +78,12 @@ def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
"acts": 3200,
"acts2": [3200] + [0] * (max_spindles - 1),
"axis_names": axis_names[:max_axis],
# Per-axis position decimal places served by cnc_getfigure (command 0x00d3).
# Default every configured axis to 0 ⇒ 10^0 = 1.0 scale, leaving existing
# position reads unchanged. mock_patch can override individual axes.
# Per-axis position scale, served INSIDE each cnc_rddynamic2 (0x0026) block as
# base 10 / this exponent — NOT by a separate command (0x00d3 "cnc_getfigure" was
# never real; the mock refuses it with EW_FUNC like the control does). Default 0 ⇒
# 10^0 = 1.0, leaving existing position reads unchanged. mock_patch can override an
# axis with an int exponent, or a dict {"base": .., "exponent": ..} — e.g.
# {"exponent": 65535} for the vacant marker.
"position_figures": {name: 0 for name in axis_names[:max_axis]},
"spindle_names": spindle_names,
"parameters": {
@@ -7,6 +7,7 @@ from typing import Any, Awaitable, Callable
from .constants import (
ADMIN_METHODS,
EW_ATTRIB,
EW_FUNC,
EW_HANDLE,
EW_NUMBER,
@@ -24,7 +25,52 @@ from .profiles import load_profile
Handler = Callable[[dict[str, Any]], Awaitable[tuple[int, dict[str, Any] | None, str | None]]]
class WireRefusal(Exception):
"""One response block refuses with this FOCAS ``rc``.
Raised from a payload builder when the COMMAND exists but its ARGUMENTS name nothing the
control would answer (``cnc_rddynamic2`` with ``arg2 = 0``, for instance). Command-level
refusals live in ``FocasMockServer._WIRE_REFUSED_COMMANDS``. Ported from the sister
ignitionoee mock (Focas.Wire-020).
"""
def __init__(self, rc: int) -> None:
super().__init__(f"refused with rc={rc}")
self.rc = rc
class FocasMockServer:
# OtOpcUa-local changes to this vendored snapshot (2026-10-07), ported from the sister
# ignitionoee FOCAS mock so the wire shapes match a real FANUC 31i-B (Makino Z-34184) rather
# than shapes the driver and the mock once agreed on between themselves:
# - 0x00D3 ("cnc_getfigure") is refused with EW_FUNC, as the real control does;
# - 0x0026 position blocks carry their own base/exponent;
# - cnc_rdmacro is the 8-byte ODBM shape with dec_val at [6..7];
# - servo/spindle meter records carry the base/exponent envelope, spindle meters in
# 1/16384-of-full-scale counts.
# The little-endian cnc_rdtimer encoding and the alarm count cap stay as they were.
# cnc_rdmacro (ODBM) encoding constants — see _wire_rdmacro / _normalize_macro.
_MACRO_ODBM_LENGTH = 10
_MACRO_VACANT_DEC = -1
_MACRO_SIGNIFICANT_DIGITS = 9
# Commands this mock REFUSES, and with which rc — the real control's answer. 0x00D3 does not
# exist in the FOCAS wire protocol; serving it let a driver validate a command the real
# 31i-B refuses with rc=1.
_WIRE_REFUSED_COMMANDS = {0xD3: EW_FUNC}
# Default base served inside a 0x0026 position block (every block of the live 31i-B
# capture reports base 10).
_POSITION_DEFAULT_BASE = 10
# Servo/spindle meter records carry the same base/exponent envelope (live: base 10, exp 0).
_METER_BASE = 10
# SPINDLE meters (0x0040) are a fraction of full scale in 1/16384 units: 16384 = 100% of
# rated load (metric 0) or of maximum spindle speed (metric 1). SERVO meters are direct %.
_METER_FULL_SCALE = 16384
def __init__(
self,
host: str,
@@ -208,10 +254,22 @@ class FocasMockServer:
def _wire_response_block(self, request_block: bytes) -> bytes:
command = int.from_bytes(request_block[6:8], "big") if len(request_block) >= 8 else 0
payload = self._wire_payload(command, request_block)
refused_rc = self._WIRE_REFUSED_COMMANDS.get(command)
if refused_rc is not None:
return self._make_wire_response_block(command, b"", request_block, rc=refused_rc)
try:
payload = self._wire_payload(command, request_block)
except WireRefusal as refusal:
return self._make_wire_response_block(command, b"", request_block, rc=refusal.rc)
return self._make_wire_response_block(command, payload, request_block)
def _make_wire_response_block(self, command: int, payload: bytes, request_block: bytes | None = None) -> bytes:
def _make_wire_response_block(
self,
command: int,
payload: bytes,
request_block: bytes | None = None,
rc: int = EW_OK,
) -> bytes:
block_len = 0x10 + len(payload)
block = bytearray(block_len)
block[0:2] = block_len.to_bytes(2, "big")
@@ -219,6 +277,7 @@ class FocasMockServer:
block[2:8] = request_block[2:8]
else:
block[6:8] = command.to_bytes(2, "big")
block[8:10] = int(rc).to_bytes(2, "big", signed=True)
block[14:16] = len(payload).to_bytes(2, "big")
block[16:] = payload
return bytes(block)
@@ -256,8 +315,6 @@ class FocasMockServer:
return self._wire_spindle_metric(request_block)
if command == 0x56:
return self._wire_servo_meter()
if command == 0xD3:
return self._wire_position_figures()
if command == 0x57:
mode = self.store.snapshot()["operation_mode"]
return self._u16(int(mode.get("mode", 0) if isinstance(mode, dict) else mode))
@@ -322,23 +379,54 @@ class FocasMockServer:
return self._u32(value)
def _wire_dynamic_axis(self, request_block: bytes) -> bytes:
"""Serves `cnc_rddynamic2` (0x0026) positions, honouring a SIGNED `arg2` the way the real
31i-B does (probed 2026-08-12 by the sister ignitionoee project, Focas.Wire-017/020):
`arg2 = 1..n` -> exactly one 8-byte block for that axis; `arg2 = -1` -> every slot in one
block; `arg2 = 0` or past the axis count -> refused (EW_ATTRIB)."""
selector = self._block_u32(request_block, 8)
axis = self._block_u32(request_block, 12)
axis = self._block_i32(request_block, 12)
field = {4: "absolute", 1: "machine", 6: "relative", 7: "distance"}.get(selector, "absolute")
state = self.store.snapshot()
names = list(state["axis_names"])
dynamic_axes = state["dynamic"]["axes"]
if axis == 0 or axis > len(names):
raise WireRefusal(EW_ATTRIB)
if axis > 0:
name = names[axis - 1]
return self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0))
payload = bytearray()
for name in names:
position = dynamic_axes.get(name, {})
payload += self._u32(int(position.get(field, 0)))
payload += self._u32(0)
if axis > 0 and axis <= len(names):
name = names[axis - 1]
position = dynamic_axes.get(name, {})
return self._u32(int(position.get(field, 0))) + self._u32(0)
payload += self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0))
return bytes(payload)
def _wire_position_block(self, state: dict[str, Any], name: str, raw: int) -> bytes:
"""Encodes one 8-byte `cnc_rddynamic2` position block: value, then its OWN scale.
[0..3] i32 raw value
[4..5] u16 base (2 or 10)
[6..7] u16 exponent (0xFFFF = the slot holds no valid position)
`position_figures[name]` is the exponent (base 10); a dict value
`{"base": .., "exponent": ..}` overrides the base too, so a profile can serve a
deliberately undecodable block and prove the client Bads that one axis node.
"""
base, exponent = self._position_scale(state, name)
return (
self._u32(int(raw))
+ (base & 0xFFFF).to_bytes(2, "big")
+ (exponent & 0xFFFF).to_bytes(2, "big")
)
def _position_scale(self, state: dict[str, Any], name: str) -> tuple[int, int]:
"""Returns the (base, exponent) pair `position_figures` configures for one axis."""
configured = state.get("position_figures", {}).get(name, 0)
if isinstance(configured, dict):
return (
int(configured.get("base", self._POSITION_DEFAULT_BASE)),
int(configured.get("exponent", 0)),
)
return self._POSITION_DEFAULT_BASE, int(configured)
def _wire_wrparam(self, request_block: bytes) -> None:
datano = self._block_u32(request_block, 8)
extra = request_block[28:]
@@ -346,11 +434,38 @@ class FocasMockServer:
self.store.merge_patch({"parameters": {str(datano): {"type": "long", "value": value, "decimal": 0}}})
def _wire_rdmacro(self, request_block: bytes) -> bytes:
"""Encodes an ODBM `cnc_rdmacro` (0x0015) response payload, as a real 31i-B sends it
(golden capture `ref-macro-500-0x0015.bin`: `05 f5 e1 00 00 0a 00 08` = 1.0):
[0..3] i32 mantissa (normalised to 9 significant digits)
[4..5] i16 length field — a CONSTANT 10
[6..7] i16 dec_val — decimal places; 0xFFFF (-1) = VACANT
A macro the profile does not mock is emitted as vacant, as a real control answers an
undefined variable.
"""
number = self._block_u32(request_block, 8)
macro = self.store.snapshot()["macros"].get(str(number), {})
value = int(macro.get("value", 0)) if isinstance(macro, dict) else 0
decimal = int(macro.get("decimal", 0)) if isinstance(macro, dict) else 0
return self._u32(value) + self._u16(decimal) + self._u16(0)
macro = self.store.snapshot()["macros"].get(str(number))
if isinstance(macro, dict):
mantissa, dec_val = self._normalize_macro(
int(macro.get("value", 0)), int(macro.get("decimal", 0))
)
else:
mantissa, dec_val = 0, self._MACRO_VACANT_DEC
return self._u32(mantissa) + self._u16(self._MACRO_ODBM_LENGTH) + self._u16(dec_val)
def _normalize_macro(self, value: int, decimal: int) -> tuple[int, int]:
"""Turns a configured fixed-point `(value, decimal)` into the `(mantissa, dec_val)` a CNC
returns: the mantissa padded to 9 significant digits, `dec_val` raised to match
(live: `#502 = 270` -> `270000000/10^6`). The real value is unchanged."""
if decimal < 0:
return 0, self._MACRO_VACANT_DEC
if value == 0:
return 0, decimal
pad = self._MACRO_SIGNIFICANT_DIGITS - len(str(abs(value)))
if pad <= 0:
return value, decimal
return value * (10**pad), decimal + pad
def _wire_wrmacro(self, request_block: bytes) -> None:
number = self._block_u32(request_block, 8)
@@ -377,31 +492,34 @@ class FocasMockServer:
payload = bytearray()
for name in state["axis_names"]:
entry = configured.get(name, {"name": name, "value": 0})
# 8-byte record (live 31i-B): value, then the base/exponent envelope. No axis name —
# the client pairs names positionally from the companion 0x0089 block.
payload += self._u32(int(entry.get("value", 0)))
payload += self._u16(0)
payload += self._u16(1)
payload += self._name_record(name, 4)
return bytes(payload)
def _wire_position_figures(self) -> bytes:
state = self.store.snapshot()
figures = state.get("position_figures", {})
payload = bytearray()
for name in state["axis_names"]:
payload += self._u16(int(figures.get(name, 0)))
payload += self._u16(self._METER_BASE)
payload += self._u16(int(entry.get("exponent", 0)))
return bytes(payload)
def _wire_spindle_metric(self, request_block: bytes) -> bytes:
# Both metrics are 1/16384-of-full-scale COUNTS on the wire, not the human units the
# config holds. Metric 0: load % of rated (16384 = 100%). Metric 1: motor speed as a
# fraction of the spindle's maximum (`speed / max_rpm`) — NOT the maximum itself.
metric = self._block_u32(request_block, 8)
spindle = self.store.snapshot()["spindle"]
if metric == 1:
values = [int(value) for value in spindle.get("max_rpm", [])]
else:
values = [int(entry.get("load", 0)) for entry in spindle.get("load", [])]
maxima = [int(value) for value in spindle.get("max_rpm", [])]
entries = spindle.get("load", [])
values = []
for index, entry in enumerate(entries):
if metric == 1:
maximum = maxima[index] if index < len(maxima) else 0
speed = int(entry.get("speed", 0))
values.append(round(speed * self._METER_FULL_SCALE / maximum) if maximum else 0)
else:
values.append(round(int(entry.get("load", 0)) * self._METER_FULL_SCALE / 100))
payload = bytearray()
for value in values[:4]:
payload += self._u32(value)
payload += self._u32(0)
payload += self._u16(self._METER_BASE)
payload += self._u16(0)
return bytes(payload)
def _wire_program_name(self) -> bytes:
@@ -480,6 +598,12 @@ class FocasMockServer:
return 0
return int.from_bytes(block[offset : offset + 4], "big")
def _block_i32(self, block: bytes, offset: int) -> int:
"""A SIGNED request argument — `-1` is a real selector (all axes / all spindles)."""
if offset + 4 > len(block):
return 0
return int.from_bytes(block[offset : offset + 4], "big", signed=True)
def _u16(self, value: int) -> bytes:
return int(value).to_bytes(2, "big", signed=True)
@@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests
}
/// <summary>
/// Verifies that per-axis position figures fetched via <c>cnc_getfigure</c> (wire command
/// 0x00D3) scale the published <c>AbsolutePosition</c>, and that the auto figure wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob.
/// Seed: X axis absolute = 12345, figure = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the wire path.
/// Verifies that the scale carried INSIDE each <c>cnc_rddynamic2</c> position block
/// (base 10 / exponent) scales the published <c>AbsolutePosition</c>, and that it wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob. (The mock refuses the old
/// <c>cnc_getfigure</c> 0x00D3 with EW_FUNC, as a real 31i-B does.)
/// Seed: X axis absolute = 12345, exponent = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the block scale.
/// </summary>
[Fact]
public async Task Position_figures_scale_axis_via_wire_backend()
public async Task Position_block_scale_scales_axis_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -261,13 +262,13 @@ public sealed class WireBackendCoverageTests
X = new { absolute = 12345, machine = 12345, relative = 0, distance = 0 },
},
},
// Per-axis decimal-place figures for cnc_getfigure (command 0x00D3).
// The mock's _wire_position_figures() reads state["position_figures"][axisName].
// Per-axis exponent served inside each 0x0026 position block (base 10).
// The mock's _position_scale() reads state["position_figures"][axisName].
position_figures = new { X = 3 },
}, ct);
// PositionDecimalPlaces = 1 is intentionally different from the auto figure (3)
// so the assertion 12.345 uniquely proves the cnc_getfigure path won.
// PositionDecimalPlaces = 1 is intentionally different from the block exponent (3)
// so the assertion 12.345 uniquely proves the block's own scale won.
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost, PositionDecimalPlaces: 1)],
@@ -295,13 +296,90 @@ public sealed class WireBackendCoverageTests
state.ShouldNotBeNull();
var published = state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"];
// 12345 ÷ 10^3 = 12.345 → auto figure (3) won.
// 12345 ÷ 10^3 = 12.345 → the block's own scale (3) won.
// 12345 ÷ 10^1 = 1234.5 → would mean config knob (1) was used instead.
// 12345 ÷ 10^0 = 12345.0 → would mean no scaling at all.
published.ShouldBe(12.345, tolerance: 0.0001);
}
}
/// <summary>
/// "The control described no value" reads Bad over the real wire, never a Good number:
/// a vacant <c>0x0026</c> position block (exponent <c>0xFFFF</c>) Bads only its own axis
/// node, an unset macro (vacant <c>dec_val</c>) Bads its tag, and a fractional macro on an
/// integer tag is refused rather than truncated — while a normalised 9-digit macro
/// mantissa de-scales to its real value.
/// </summary>
[Fact]
public async Task Vacant_positions_and_macros_read_bad_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
axis_names = new[] { "X", "Y" },
dynamic = new
{
alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0,
axes = new
{
X = new { absolute = 1, machine = 1, relative = 0, distance = 0 },
Y = new { absolute = 370177, machine = 370177, relative = 0, distance = 0 },
},
},
position_figures = new Dictionary<string, object>
{
["X"] = new { exponent = 0xFFFF }, // vacant-slot marker
["Y"] = 3,
},
macros = new Dictionary<string, object>
{
["502"] = new { value = 270, @decimal = 0 }, // served as 270000000 / 10^6
["508"] = new { value = 1485, @decimal = 1 }, // 148.5
},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Count502", DeviceHost, "MACRO:502", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Frac508", DeviceHost, "MACRO:508", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Unset611", DeviceHost, "MACRO:611", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-vacant", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() =>
drv.GetDeviceState(DeviceHost)?.LastFixedSnapshots
.ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition") == true,
TimeSpan.FromSeconds(5));
var snaps = await drv.ReadAsync(
[$"{DeviceHost}/Axes/X/AbsolutePosition", $"{DeviceHost}/Axes/Y/AbsolutePosition",
"Count502", "Frac508", "Unset611"], ct);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
Convert.ToDouble(snaps[1].Value).ShouldBe(370.177, tolerance: 1e-9);
snaps[2].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[2].Value.ShouldBe(270);
snaps[3].StatusCode.ShouldBe(FocasStatusMapper.BadTypeMismatch);
snaps[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -210,9 +210,13 @@ public sealed class WireBackendTests
}
}
/// <summary>Verifies that spindle load and max RPM data populate via the wire backend.</summary>
/// <summary>
/// Verifies that the spindle load and motor-speed meters populate via the wire backend as
/// percentages of full scale (the mock serves 1/16384-of-full-scale counts, as a real
/// 31i-B does): S1 load 56% / speed 3200 of 6000 rpm = 53.3%, S2 load 12% / 1800 of 4500 = 40%.
/// </summary>
[Fact]
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
public async Task Spindle_load_and_motor_speed_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -260,21 +264,21 @@ public sealed class WireBackendTests
await WaitFor(() =>
{
var state = driver.GetDeviceState(DeviceHost);
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true }
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMotorSpeed: true }
&& state.LastSpindleLoads.Count >= 2;
}, TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Spindle/S1/Load",
$"{DeviceHost}/Spindle/S1/MaxRpm",
$"{DeviceHost}/Spindle/S1/MotorSpeedPct",
$"{DeviceHost}/Spindle/S2/Load",
$"{DeviceHost}/Spindle/S2/MaxRpm"], ct);
$"{DeviceHost}/Spindle/S2/MotorSpeedPct"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snapshots[0].Value).ShouldBe(56);
Convert.ToInt32(snapshots[1].Value).ShouldBe(6000);
Convert.ToInt32(snapshots[2].Value).ShouldBe(12);
Convert.ToInt32(snapshots[3].Value).ShouldBe(4500);
Convert.ToDouble(snapshots[0].Value).ShouldBe(56.0, tolerance: 0.01);
Convert.ToDouble(snapshots[1].Value).ShouldBe(3200 * 100.0 / 6000, tolerance: 0.01);
Convert.ToDouble(snapshots[2].Value).ShouldBe(12.0, tolerance: 0.01);
Convert.ToDouble(snapshots[3].Value).ShouldBe(40.0, tolerance: 0.01);
}
}
@@ -140,25 +140,18 @@ internal class FakeFocasClient : IFocasClient
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
/// <summary>Gets the list of spindle loads.</summary>
public List<int> SpindleLoads { get; } = [];
/// <summary>Gets the list of spindle maximum RPMs.</summary>
public List<int> SpindleMaxRpms { get; } = [];
/// <summary>Gets the list of spindle load percentages (null = undescribable slot).</summary>
public List<double?> SpindleLoads { get; } = [];
/// <summary>Gets the list of spindle motor-speed percentages (null = undescribable slot).</summary>
public List<double?> SpindleMotorSpeeds { get; } = [];
/// <summary>Gets spindle loads asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
/// <summary>Gets spindle maximum RPMs asynchronously.</summary>
public virtual Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<double?>>([.. SpindleLoads]);
/// <summary>Gets spindle motor-speed percentages asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
/// <summary>Gets or sets the per-axis position decimal-place figures returned by <see cref="GetPositionFiguresAsync"/>.</summary>
public IReadOnlyList<int> PositionFigures { get; set; } = [];
/// <summary>Gets the per-axis position decimal-place figures asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetPositionFiguresAsync(CancellationToken ct) =>
Task.FromResult(PositionFigures);
public virtual Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<double?>>([.. SpindleMotorSpeeds]);
/// <summary>Disposes the client.</summary>
public virtual void Dispose()
@@ -34,8 +34,8 @@ public sealed class FocasDuplicateSiblingNameTests
// Two spindles whose displays also collide, with distinct data per slot.
c.SpindleNames.Clear();
c.SpindleNames.AddRange([new("S", "1", "", ""), new("S", "1", "", "")]);
c.SpindleLoads.AddRange([5, 7]);
c.SpindleMaxRpms.AddRange([8_000, 12_000]);
c.SpindleLoads.AddRange([5.0, 7.0]);
c.SpindleMotorSpeeds.AddRange([40.0, 60.0]);
return c;
},
};
@@ -159,17 +159,17 @@ public sealed class FocasDuplicateSiblingNameTests
// The spindle read path matched by display name and took the FIRST index that matched,
// so a duplicate spindle name made the second spindle's data unreachable.
var drv = await StartedDriverAsync(
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("raw/s1max", Host, "FIXED:Spindle/S1_1/MaxRpm", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("raw/s2max", Host, "FIXED:Spindle/S1_2/MaxRpm", FocasDataType.Int32, Writable: false));
new FocasTagDefinition("raw/s1load", Host, "FIXED:Spindle/S1_1/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2load", Host, "FIXED:Spindle/S1_2/Load", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s1speed", Host, "FIXED:Spindle/S1_1/MotorSpeedPct", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("raw/s2speed", Host, "FIXED:Spindle/S1_2/MotorSpeedPct", FocasDataType.Float64, Writable: false));
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1max", "raw/s2max"], CancellationToken.None);
var read = await drv.ReadAsync(["raw/s1load", "raw/s2load", "raw/s1speed", "raw/s2speed"], CancellationToken.None);
read[0].Value.ShouldBe(5);
read[1].Value.ShouldBe(7);
read[2].Value.ShouldBe(8_000);
read[3].Value.ShouldBe(12_000);
read[0].Value.ShouldBe(5.0);
read[1].Value.ShouldBe(7.0);
read[2].Value.ShouldBe(40.0);
read[3].Value.ShouldBe(60.0);
read.ShouldAllBe(r => r.StatusCode == FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
@@ -0,0 +1,143 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// <c>cnc_rdmacro</c> (0x0015) decode, pinned to real FANUC 31i-B payloads. Ported from the
/// sister ignitionoee FOCAS driver's v0.15.0 fix (review finding Focas.Wire-013 / Focas-017).
/// </summary>
/// <remarks>
/// The on-wire <c>ODBM</c> payload is 8 bytes: <c>[0..3] i32 mantissa</c>,
/// <c>[4..5] i16</c> — a <b>constant 10</b> on every observed response (a length field, NOT the
/// decimal), and <c>[6..7] i16 dec_val</c> (<c>0xFFFF</c> = vacant). Reading the decimal at
/// offset 4 decodes every macro on every control without ever failing — just wrongly: the
/// captured <c>#500 = 1.0</c> (<c>05 f5 e1 00 00 0a 00 08</c>) read as <c>100000000 / 10^10 =
/// 0.01</c>. FANUC normalises a macro to a 9-significant-digit mantissa, so the live vectors
/// below (Makino Z-34184, 2026-08-05) all carry large mantissas.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class FocasMacroDecodeTests
{
private static byte[] Odbm(int mantissa, int decVal, int lengthField = 10)
{
var p = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(p.AsSpan(0, 4), mantissa);
BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(4, 2), (ushort)lengthField);
BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(6, 2), (ushort)(decVal & 0xFFFF));
return p;
}
[Fact]
public void ParseMacro_decodes_the_live_capture_with_the_decimal_at_offset_6()
{
var payload = WireFixtures.Payload("ref-macro-500-0x0015.bin", 0x0015);
var m = FocasWireClient.ParseMacro(500, payload);
m.Number.ShouldBe((short)500);
m.Value.ShouldBe(100_000_000);
m.Decimal.ShouldBe((short)8); // offset 6 — NOT the constant 10 at offset 4
m.IsVacant.ShouldBeFalse();
m.Scaled!.Value.ShouldBe(1.0, 1e-12);
}
[Fact]
public void ParseMacro_ignores_the_length_field_at_offset_4()
{
var a = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6));
var b = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6, lengthField: 0));
b.ShouldBe(a);
b.Scaled!.Value.ShouldBe(148.5, 1e-9);
}
[Theory]
[InlineData(270_000_000, 6, 270.0)] // #502
[InlineData(148_500_000, 6, 148.5)] // #508
[InlineData(208_850_000, 4, 20885.0)] // #610
[InlineData(202_608_050, 1, 20_260_805.0)] // #3011 date
[InlineData(-100_000_000, 12, -1.0e-4)] // #802
[InlineData(969_479_000, 9, 0.969479)] // #801
[InlineData(1234, 0, 1234.0)]
public void ParseMacro_scales_the_live_vectors(int mantissa, int dec, double expected)
{
FocasWireClient.ParseMacro(1, Odbm(mantissa, dec)).Scaled!.Value.ShouldBe(expected, 1e-9);
}
[Fact]
public void A_vacant_macro_has_no_value_not_zero()
{
var m = FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF));
m.Decimal.ShouldBe((short)-1);
m.IsVacant.ShouldBeTrue();
m.Scaled.ShouldBeNull();
}
[Fact]
public void A_genuine_zero_is_not_vacant()
{
var m = FocasWireClient.ParseMacro(3901, Odbm(0, 0));
m.IsVacant.ShouldBeFalse();
m.Scaled.ShouldBe(0.0);
}
[Theory]
[InlineData(0)]
[InlineData(4)]
[InlineData(6)]
[InlineData(7)]
public void ParseMacro_refuses_a_payload_too_short_to_carry_dec_val(int length)
{
Should.Throw<FocasWireException>(() => FocasWireClient.ParseMacro(1, new byte[length]));
}
// ---- published value / quality ----
[Fact]
public void Float64_publishes_the_scaled_value_good()
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Float64, null);
status.ShouldBe(FocasStatusMapper.Good);
value.ShouldBe(148.5);
}
[Fact]
public void Int32_publishes_the_exact_whole_value_not_the_mantissa()
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(502, Odbm(270_000_000, 6)), FocasDataType.Int32, null);
status.ShouldBe(FocasStatusMapper.Good);
value.ShouldBe(270);
}
[Fact]
public void A_fractional_macro_on_an_integer_tag_is_bad_not_truncated()
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Int32, null);
status.ShouldBe(FocasStatusMapper.BadTypeMismatch);
value.ShouldBeNull();
}
[Theory]
[InlineData(FocasDataType.Float64)]
[InlineData(FocasDataType.Float32)]
[InlineData(FocasDataType.Int32)]
public void A_vacant_macro_is_bad_for_every_type(FocasDataType type)
{
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF)), type, null);
status.ShouldBe(FocasStatusMapper.BadNoData);
value.ShouldBeNull();
}
[Fact]
public void A_whole_macro_out_of_range_for_its_integer_type_is_bad()
{
// 70000 is whole, but does not fit Int16.
var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(1, Odbm(700_000_000, 4)), FocasDataType.Int16, null);
status.ShouldBe(FocasStatusMapper.BadOutOfRange);
value.ShouldBeNull();
}
}
@@ -0,0 +1,127 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Servo (<c>cnc_rdsvmeter</c>, 0x0056) and spindle (<c>cnc_rdspmeter</c>, 0x0040) meter decode.
/// Both are arrays of 8-byte records carrying the same self-describing envelope as every other
/// value block on this wire — <c>[+0..3] i32 value</c>, <c>[+4..5] u16 base</c> (2 or 10),
/// <c>[+6..7] u16 exponent</c> — so <c>scaled = value / base^exponent</c>.
/// Ported from the sister ignitionoee FOCAS driver v0.17.2 / v0.17.3 (Focas.Wire-019).
/// </summary>
/// <remarks>
/// <para><b>Servo: offset 4 is the BASE, not a decimal count.</b> It holds a steady 10, so the
/// old <c>value / 10^decimal</c> divided every load by 10^10 and published ~0 — on a real
/// Makino cutting at 20,000 rpm the panel read servo 0% on every axis. Servo meters are
/// <b>direct</b> percentages (the live capture shows −57..227).</para>
/// <para><b>Spindle: a fraction of full scale in 1/16384 units.</b> Settled on the real Z-34184
/// by regressing the type-1 meter against the independently read <c>cnc_rddynamic2</c> spindle
/// speed over 596–20,000 rpm: <c>rpm = 1.220749 × meter</c>, and <c>16384 × 1.220749 =
/// 20000.75</c> — full scale is the spindle's 20,000 rpm maximum. So the type-1 meter
/// (<c>cnc_rdspmaxrpm</c> by name) is the <b>motor speed as a % of maximum</b>, not a max-rpm
/// constant; the type-0 load meter shares the envelope and the 16384 = 100% scale (its
/// acceleration peaks pin at 32767 — one count short of a 200% overload).</para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class FocasMeterDecodeTests
{
private static byte[] Record(int value, int @base, int exponent)
{
var b = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), value);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent);
return b;
}
// ---- servo ----
[Fact]
public void The_live_servo_capture_reads_direct_percentages_not_value_over_ten_to_the_tenth()
{
var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin");
var meters = FocasWireClient.ParseServoMeters(
blocks.First(b => b.Command == 0x0056).Payload,
blocks.First(b => b.Command == 0x0089).Payload,
maxCount: 32);
meters[0].Base.ShouldBe(10);
meters[0].Exponent.ShouldBe(0);
meters.Take(3).Select(m => m.Scaled).ShouldBe([0.0, 50.0, -3.0]);
}
[Fact]
public void A_servo_record_applies_its_own_exponent()
{
var m = FocasWireClient.ParseServoMeters(Record(1234, 10, 1), Array.Empty<byte>(), 32).Single();
m.Scaled!.Value.ShouldBe(123.4, 1e-9);
}
[Theory]
[InlineData(5, 7, 0)] // base neither 2 nor 10
[InlineData(5, 10, 2560)] // residue past the controlled-axis count
public void An_undescribable_servo_record_has_no_value_not_zero(int value, int @base, int exponent)
{
var m = FocasWireClient.ParseServoMeters(Record(value, @base, exponent), Array.Empty<byte>(), 32).Single();
m.IsValid.ShouldBeFalse();
m.Scaled.ShouldBeNull();
}
// ---- spindle ----
[Fact]
public void The_live_spindle_load_capture_is_a_percentage_of_16384()
{
var metrics = FocasWireClient.ParseSpindleMetrics(
WireFixtures.Payload("live-spmeter-load-0x0040.bin", 0x0040));
// Slot 1: raw 0x23C3 = 9155, base 10, exponent 0 → 9155 × 100 / 16384 ≈ 55.88 %.
metrics[0].Value.ShouldBe(9155);
metrics[0].Percent!.Value.ShouldBe(9155 * 100.0 / 16384, 1e-9);
// Slot 2 is buffer residue (exponent 0x0A00 = 2560): no value, not a number.
metrics[1].IsValid.ShouldBeFalse();
metrics[1].Percent.ShouldBeNull();
}
[Fact]
public void The_live_motor_speed_capture_is_a_percentage_of_maximum_speed()
{
var metrics = FocasWireClient.ParseSpindleMetrics(
WireFixtures.Payload("live-spmeter-maxrpm-0x0040.bin", 0x0040));
// 0x3FFD = 16381 ≈ 99.98 % — a spindle at (just under) its 20,000 rpm maximum.
metrics[0].Value.ShouldBe(16381);
metrics[0].Percent!.Value.ShouldBe(16381 * 100.0 / 16384, 1e-9);
}
[Theory]
[InlineData(16382, 19998)]
[InlineData(12286, 14998)]
[InlineData(9830, 12000)]
[InlineData(695, 850)]
[InlineData(488, 595)]
public void Percent_reproduces_the_live_motor_speed_regression(int meter, int rpm)
{
var m = FocasWireClient.ParseSpindleMetrics(Record(meter, 10, 0)).Single();
m.Percent!.Value.ShouldBe(rpm * 100.0 / 20000.0, 0.02);
}
[Fact]
public void Full_scale_is_16384_and_overload_is_not_clamped()
{
WireSpindleMetric.FullScale.ShouldBe(16384);
FocasWireClient.ParseSpindleMetrics(Record(16384, 10, 0)).Single().Percent.ShouldBe(100.0);
var overload = FocasWireClient.ParseSpindleMetrics(Record(32767, 10, 0)).Single().Percent!.Value;
overload.ShouldBeInRange(199.9, 200.0);
}
[Fact]
public void The_spindle_envelope_exponent_applies_before_the_full_scale_division()
{
// 163840 / 10^1 = 16384 counts = 100 %.
FocasWireClient.ParseSpindleMetrics(Record(163840, 10, 1)).Single().Percent!.Value.ShouldBe(100.0, 1e-9);
}
}
@@ -0,0 +1,134 @@
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>
/// Driver-level publish of servo / spindle meters (Focas.Wire-019 port): percentages publish
/// Good as Float64, an undescribable record publishes BadNoData (never a Good 0), and the
/// spindle motor-speed meter is a live, polled <c>MotorSpeedPct</c> — not the bootstrap-time
/// <c>MaxRpm</c> constant it was mislabelled as.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasMeterPublishTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(FakeFocasClient client) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task<DataValueSnapshot> ReadSettledAsync(FocasDriver drv, string reference, Func<DataValueSnapshot, bool> settled)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
DataValueSnapshot last;
do
{
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
if (settled(last)) return last;
await Task.Delay(15);
} while (DateTime.UtcNow < deadline);
return last;
}
private static FakeFocasClient MachineClient()
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.AddRange([new("X", ""), new("Y", "")]);
c.ServoLoads.AddRange([new("X", 46.0), new("Y", null)]); // Y: undescribable record
c.SpindleNames.Clear();
c.SpindleNames.Add(new("S", "1", "", ""));
c.SpindleLoads.Add(55.88);
c.SpindleMotorSpeeds.Add(2.49);
return c;
}
[Fact]
public async Task Servo_loads_publish_percentages_and_an_undescribable_record_is_bad()
{
var drv = NewDriver(MachineClient());
await drv.InitializeAsync("{}", CancellationToken.None);
var x = await ReadSettledAsync(drv, $"{Host}/Axes/X/ServoLoad", s => s.StatusCode == FocasStatusMapper.Good);
var y = await ReadSettledAsync(drv, $"{Host}/Axes/Y/ServoLoad", s => s.StatusCode == FocasStatusMapper.BadNoData);
x.Value.ShouldBe(46.0);
y.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
y.Value.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Spindle_meters_publish_float64_percentages_and_motor_speed_is_polled_live()
{
var client = MachineClient();
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
load.Value.ShouldBe(55.88);
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => s.StatusCode == FocasStatusMapper.Good))
.Value.ShouldBe(2.49);
// The spindle speeds up: the poll loop must follow it (MaxRpm was read once at bootstrap).
client.SpindleMotorSpeeds[0] = 99.98;
(await ReadSettledAsync(drv, $"{Host}/Spindle/S1/MotorSpeedPct", s => Equals(s.Value, 99.98)))
.Value.ShouldBe(99.98);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task An_undescribable_spindle_slot_is_bad_not_zero()
{
var client = MachineClient();
client.SpindleLoads[0] = null;
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
var load = await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.BadNoData);
load.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
load.Value.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_spindle_folder_offers_MotorSpeedPct_not_MaxRpm()
{
var drv = NewDriver(MachineClient());
await drv.InitializeAsync("{}", CancellationToken.None);
await ReadSettledAsync(drv, $"{Host}/Spindle/S1/Load", s => s.StatusCode == FocasStatusMapper.Good);
var builder = new NameRecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldContain("MotorSpeedPct");
builder.Variables.ShouldNotContain("MaxRpm");
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class NameRecordingBuilder : IAddressSpaceBuilder
{
public List<string> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(browseName);
return new Handle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
}
}
}
@@ -0,0 +1,78 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Axis-name (0x0089) / spindle-name (0x008A) record decode. Each record is 4 bytes; the name is
/// the ASCII up to the record's FIRST NUL. Ported from the sister ignitionoee FOCAS driver
/// (Focas.Wire-001 and Focas.Wire-022, v0.18.1).
/// </summary>
/// <remarks>
/// <para><b>Reading only 2 bytes collides real names.</b> The live 31i-B capture
/// (<c>ref-axisname-0x0089.bin</c>) carries <c>"AA6"</c> and <c>"AA7"</c>; the old 2-byte read
/// turned both into <c>"AA"</c>, which is exactly the "one observed Makino returns two axis
/// folders both called AA" that Gitea #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
/// <para><b>Trailing-only trimming keeps buffer residue.</b> A control writes only the bytes the
/// name needs; on Makino Z-34184 (2026-08-19) <c>cnc_rdspdlname</c> came back
/// <c>53 31 00 4e</c> — <c>"S1"</c> then the <c>'N'</c> of an earlier <c>"CNC"</c>. The name
/// ends at the first NUL.</para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class FocasNameRecordTests
{
[Fact]
public void The_live_axis_name_capture_keeps_three_character_names_distinct()
{
var payload = WireFixtures.Payload("ref-axisname-0x0089.bin", 0x0089);
var names = FocasWireClient.ReadNameRecords(payload, 32, (index, name) => (index, name));
names.Select(n => n.name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]);
names.Select(n => n.index).ShouldBe([(short)1, 2, 3, 4, 5, 6, 7]);
}
[Fact]
public void The_live_spindle_name_capture_decodes()
{
var payload = WireFixtures.Payload("ref-spdlname-0x008a.bin", 0x008a);
FocasWireClient.ReadNameRecords(payload, 8, (_, name) => name).ShouldBe(["S1"]);
}
[Fact]
public void A_name_ends_at_its_first_NUL_so_residue_after_it_is_discarded()
{
// The Z-34184 spindle-name record: "S1", NUL, then an 'N' left over from a previous "CNC".
FocasWireProtocol.ReadNameRecord([0x53, 0x31, 0x00, 0x4e]).ShouldBe("S1");
// A single-character name with residue in BOTH trailing bytes.
FocasWireProtocol.ReadNameRecord([0x58, 0x00, 0x43, 0x4e]).ShouldBe("X");
}
[Fact]
public void All_four_record_bytes_are_name_bytes()
{
FocasWireProtocol.ReadNameRecord("AA6\0"u8.ToArray()).ShouldBe("AA6");
FocasWireProtocol.ReadNameRecord("ABCD"u8.ToArray()).ShouldBe("ABCD");
FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
}
[Fact]
public void A_record_that_starts_with_NUL_is_blank()
{
FocasWireProtocol.ReadNameRecord([0x00, 0x41, 0x41, 0x00]).ShouldBe(string.Empty);
}
[Fact]
public void Servo_meter_names_come_out_whole_too()
{
var blocks = WireFixtures.Blocks("svmeter-pair-0x0056-0x0089.bin");
var sv = blocks.First(b => b.Command == 0x0056).Payload;
var names = blocks.First(b => b.Command == 0x0089).Payload;
var meters = FocasWireClient.ParseServoMeters(sv, names, maxCount: 32);
meters.Take(7).Select(m => m.Name).ShouldBe(["X", "Y", "Z", "B", "C", "AA6", "AA7"]);
}
}
@@ -1,164 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Phase 4b — per-axis auto-scale from <c>cnc_getfigure</c> figures. The driver fetches
/// per-axis position decimal-place figures at init and applies them at the
/// <see cref="FocasDriver"/> publish seam. Precedence: an auto figure for an axis WINS;
/// the configured <c>PositionDecimalPlaces</c> is the per-axis fallback when the CNC did
/// not report a figure for that axis. These tests drive the Fake to exercise the driver's
/// auto-scale-vs-fallback logic independently of the wire path.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionAutoScaleTests
{
private const string Host = "focas://10.0.0.5:8193";
// A single-axis CNC (X only) so the snapshot we seed is unambiguous.
private static FakeFocasClient SingleAxisClient(
FocasDynamicSnapshot snap, IReadOnlyList<int> figures)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.DynamicByAxis[1] = snap; // FOCAS axis indexing is 1-based
c.PositionFigures = figures;
return c;
}
// A two-axis CNC (X, Y) — each axis gets its own seeded snapshot + figure.
private static FakeFocasClient TwoAxisClient(
FocasDynamicSnapshot xSnap, FocasDynamicSnapshot ySnap, IReadOnlyList<int> figures)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.AxisNames.Add(new("Y", ""));
c.DynamicByAxis[1] = xSnap; // axis 0 → 1-based index 1
c.DynamicByAxis[2] = ySnap; // axis 1 → 1-based index 2
c.PositionFigures = figures;
return c;
}
private static FocasDynamicSnapshot AbsSnap(int axisIndex, int absolutePosition) =>
new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0,
AbsolutePosition: absolutePosition, MachinePosition: 0,
RelativePosition: 0, DistanceToGo: 0);
private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClientFactory factory)
{
return new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(10),
},
}, "drv-1", factory);
}
// Drive the fixed-tree poll loop and wait (bounded) for the cached value to appear.
private static async Task<object?> ReadWhenPublishedAsync(FocasDriver drv, string reference)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline)
{
var snaps = await drv.ReadAsync([reference], CancellationToken.None);
var snap = snaps.Single();
if (snap.StatusCode == FocasStatusMapper.Good && snap.Value is not null)
return snap.Value;
await Task.Delay(15);
}
return null;
}
/// <summary>
/// An auto figure for an axis WINS over the configured manual value: figure 3 ⇒ ÷10^3
/// (12345 → 12.345), even though <c>PositionDecimalPlaces: 1</c> would have given ÷10.
/// </summary>
[Fact]
public async Task Auto_figure_wins_over_manual_config()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: [3]),
};
var drv = NewDriver(positionDecimalPlaces: 1, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(12.345); // ÷10^3 (auto), NOT 1234.5 (manual ÷10)
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// When <c>cnc_getfigure</c> reports nothing (empty list — the real wire backend's
/// behaviour), the driver falls back to the configured <c>PositionDecimalPlaces</c>:
/// manual 2 ⇒ ÷10^2 (12345 → 123.45).
/// </summary>
[Fact]
public async Task Falls_back_to_manual_when_getfigure_empty()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []),
};
var drv = NewDriver(positionDecimalPlaces: 2, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(123.45); // ÷10^2 (manual fallback)
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Figures are applied per-axis independently: figures [3, 1] ⇒ axis 0 ÷10^3 and
/// axis 1 ÷10^1.
/// </summary>
[Fact]
public async Task Per_axis_figures_scale_independently()
{
var factory = new FakeFocasClientFactory
{
Customise = () => TwoAxisClient(
AbsSnap(1, 12345), AbsSnap(2, 67890), figures: [3, 1]),
};
var drv = NewDriver(positionDecimalPlaces: 0, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var x = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
var y = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/Y/AbsolutePosition");
x.ShouldBe(12.345); // ÷10^3
y.ShouldBe(6789.0); // ÷10^1
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// The legacy manual-only path is unchanged: no auto figures + <c>PositionDecimalPlaces: 0</c>
/// ⇒ factor 1.0, so the raw integer is published widened to double (12345 → 12345.0).
/// </summary>
[Fact]
public async Task Manual_only_legacy_path_unchanged()
{
var factory = new FakeFocasClientFactory
{
Customise = () => SingleAxisClient(AbsSnap(1, 12345), figures: []),
};
var drv = NewDriver(positionDecimalPlaces: 0, factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPublishedAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.ShouldBe(12345.0); // factor 1.0 — raw widened to double
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,105 @@
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>
/// Driver-level publish of self-describing <c>cnc_rddynamic2</c> positions. Each block's own
/// <c>base^exponent</c> wins over the configured <c>PositionDecimalPlaces</c> (which is now only
/// the fallback for a scale-less block), and a block describing no usable position Bads its own
/// node without disturbing its siblings. Replaces the <c>cnc_getfigure</c> (0x00D3) auto-scale
/// tests — that command is not a FOCAS wire command; the real 31i-B refuses it with <c>rc=1</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionBlockScaleTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDynamicSnapshot Snap(int axisIndex, FocasPosition abs, FocasPosition mach) =>
new(AxisIndex: axisIndex, AlarmFlags: 0, ProgramNumber: 0, MainProgramNumber: 0,
SequenceNumber: 0, ActualFeedRate: 0, ActualSpindleSpeed: 0,
AbsolutePosition: abs, MachinePosition: mach, RelativePosition: 0, DistanceToGo: 0);
private static FocasDriver NewDriver(int positionDecimalPlaces, FakeFocasClient client) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, PositionDecimalPlaces: positionDecimalPlaces)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = true, PollInterval = TimeSpan.FromMilliseconds(10) },
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task<DataValueSnapshot> ReadWhenPolledAsync(FocasDriver drv, string reference)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
DataValueSnapshot last;
do
{
// Until the first poll lands the reference resolves to nothing; once it has, the node
// reads either Good or (for an undescribable block) BadNoData.
last = (await drv.ReadAsync([reference], CancellationToken.None)).Single();
if (last.StatusCode is FocasStatusMapper.Good or FocasStatusMapper.BadNoData) return last;
await Task.Delay(15);
} while (DateTime.UtcNow < deadline);
return last;
}
private static FakeFocasClient XAxisClient(FocasDynamicSnapshot snap)
{
var c = new FakeFocasClient();
c.AxisNames.Clear();
c.AxisNames.Add(new("X", ""));
c.DynamicByAxis[1] = snap;
return c;
}
[Fact]
public async Task The_blocks_own_scale_wins_over_PositionDecimalPlaces()
{
// The live 31i-B shape: raw 2801574, base 10, exponent 4 → 280.1574 mm.
var client = XAxisClient(Snap(1, new FocasPosition(2_801_574, 10, 4, HasScale: true), 0));
var drv = NewDriver(positionDecimalPlaces: 1, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.StatusCode.ShouldBe(FocasStatusMapper.Good);
((double)abs.Value!).ShouldBe(280.1574, 1e-9); // NOT 280157.4 (the ÷10 fallback)
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_scale_less_block_falls_back_to_PositionDecimalPlaces()
{
var client = XAxisClient(Snap(1, 12345, 0));
var drv = NewDriver(positionDecimalPlaces: 2, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
abs.StatusCode.ShouldBe(FocasStatusMapper.Good);
abs.Value.ShouldBe(123.45);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_vacant_block_bads_its_own_node_and_not_its_sibling()
{
var client = XAxisClient(Snap(
1,
abs: new FocasPosition(0, 10, 0xFFFF, HasScale: true), // vacant-slot marker
mach: new FocasPosition(-22000, 10, 3, HasScale: true)));
var drv = NewDriver(positionDecimalPlaces: 0, client);
await drv.InitializeAsync("{}", CancellationToken.None);
var mach = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/MachinePosition");
var abs = await ReadWhenPolledAsync(drv, $"{Host}/Axes/X/AbsolutePosition");
mach.StatusCode.ShouldBe(FocasStatusMapper.Good);
mach.Value.ShouldBe(-22.0);
abs.StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
abs.Value.ShouldBeNull("an undescribable block is not a Good 0.0 at the origin");
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,107 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// <c>cnc_rddynamic2</c> position blocks (0x0026) are self-describing: <c>[0..3] i32 raw</c>,
/// <c>[4..5] u16 base</c> (2 or 10), <c>[6..7] u16 exponent</c> (<c>0xFFFF</c> = vacant slot);
/// <c>value = raw / base^exponent</c>. Ported from the sister ignitionoee FOCAS driver v0.17.0
/// (which also deleted the fabricated <c>cnc_getfigure</c> 0x00D3 — the real 31i-B refuses it
/// with <c>rc=1</c>) and Focas.Wire-014 (absurd exponents are refused, not divided by).
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionDecodeTests
{
private static byte[] Block(int raw, int @base, int exponent)
{
var b = new byte[8];
BinaryPrimitives.WriteInt32BigEndian(b.AsSpan(0, 4), raw);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(4, 2), (ushort)@base);
BinaryPrimitives.WriteUInt16BigEndian(b.AsSpan(6, 2), (ushort)exponent);
return b;
}
[Fact]
public void The_live_dynamic2_capture_scales_every_position_from_its_own_block()
{
// ref-dynamic2-axis1.bin: four 0x0026 blocks (absolute / machine / relative / distance of
// axis 1), every one base 10, exponent 4. Undecoded they read 2801574 / 2801200 / 32 / 0.
var dynamic = FocasWireClient.ParseDynamic2(WireFixtures.Blocks("ref-dynamic2-axis1.bin"));
dynamic.Axis.Absolute.ToEngineering(0)!.Value.ShouldBe(280.1574, 1e-9);
dynamic.Axis.Machine.ToEngineering(0)!.Value.ShouldBe(280.1200, 1e-9);
dynamic.Axis.Relative.ToEngineering(0)!.Value.ShouldBe(0.0032, 1e-9);
dynamic.Axis.Distance.ToEngineering(0)!.Value.ShouldBe(0.0, 1e-9);
dynamic.FeedRate.ShouldBe(0);
dynamic.SpindleSpeed.ShouldBe(0);
}
[Fact]
public void The_block_scale_wins_over_the_configured_decimal_places()
{
var p = FocasWireClient.ParsePositionBlock(Block(123456, 10, 3));
p.ToEngineering(fallbackDecimalPlaces: 1)!.Value.ShouldBe(123.456, 1e-9);
}
[Theory]
[InlineData("live-dynamic2-axis1-0x0026.bin", 0.0374)] // Z-34184 X
[InlineData("live-dynamic2-axis2-0x0026.bin", 370.177)] // Z-34184 Y
public void Axis_scoped_live_requests_return_that_axis_scaled(string fixture, double expected)
{
var blocks = WireFixtures.Blocks(fixture).Where(b => b.Command == 0x0026).ToList();
blocks.Count.ShouldBe(1, "an axis-scoped request returns exactly one 0x0026 block");
FocasWireClient.ParsePositionBlock(blocks[0].Payload).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9);
}
[Theory]
[InlineData(40, 2, 3, 5.0)] // inch-mode binary base
[InlineData(-22000, 10, 3, -22.0)]
[InlineData(42, 10, 0, 42.0)]
public void Positions_scale_by_base_to_the_exponent(int raw, int @base, int exponent, double expected)
{
FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent)).ToEngineering(0)!.Value.ShouldBe(expected, 1e-9);
}
[Theory]
[InlineData(1, 10, 0xFFFF)] // vacant-slot marker
[InlineData(1234, 7, 2)] // base neither 2 nor 10
[InlineData(1234, 0, 2)]
[InlineData(123456, 10, 400)] // would divide by Infinity → a Good 0.0 at the origin
[InlineData(123456, 10, 40)]
public void An_undescribable_block_has_no_value(int raw, int @base, int exponent)
{
var p = FocasWireClient.ParsePositionBlock(Block(raw, @base, exponent));
p.IsValid.ShouldBeFalse();
p.ToEngineering(3).ShouldBeNull();
}
[Fact]
public void Every_plausible_exponent_still_decodes()
{
for (var exp = 0; exp <= 18; exp++)
FocasWireClient.ParsePositionBlock(Block(1, 10, exp)).IsValid.ShouldBeTrue($"exponent {exp}");
}
[Fact]
public void A_block_without_a_scale_falls_back_to_the_configured_decimal_places()
{
// A 4-byte block carries no envelope (older simulators); only then does
// PositionDecimalPlaces apply.
var p = FocasWireClient.ParsePositionBlock([0x00, 0x00, 0x30, 0x39]); // 12345
p.HasScale.ShouldBeFalse();
p.ToEngineering(2)!.Value.ShouldBe(123.45, 1e-9);
p.ToEngineering(0)!.Value.ShouldBe(12345.0);
}
[Fact]
public void A_raw_integer_position_converts_implicitly_as_a_scale_less_value()
{
FocasPosition p = 12345;
p.HasScale.ShouldBeFalse();
p.Raw.ShouldBe(12345);
}
}
@@ -5,12 +5,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Phase 4 data-type tier — axis-position scaling. <c>cnc_rddynamic2</c> returns
/// positions as scaled integers; the driver applies a <c>10^PositionDecimalPlaces</c>
/// divide at the <see cref="FocasDriver"/> publish seam so positions surface in
/// engineering units on the Float64 axis nodes. The managed wire backend fetches per-axis
/// figures live via <c>cnc_getfigure</c>; the config knob is the per-axis fallback used
/// when the CNC reports no figure for an axis. These tests cover the config-supplied path.
/// Phase 4 data-type tier — axis-position scaling, config-supplied fallback path. A real
/// CNC's <c>cnc_rddynamic2</c> position block carries its own <c>base^exponent</c> scale
/// (covered by <see cref="FocasPositionBlockScaleTests"/>); a scale-less position — the
/// raw integers these tests seed — is divided by <c>10^PositionDecimalPlaces</c> at the
/// <see cref="FocasDriver"/> publish seam.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasPositionScalingTests
@@ -0,0 +1,92 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// "The control did not say" must read Bad, never a fabricated Good 0. Ported from the sister
/// ignitionoee FOCAS driver (commit 101a3b1, 2026-08-12): a <c>cnc_exeprgname2</c> payload too
/// short to carry the O-number published <c>0</c> at Good quality — indistinguishable from a
/// 31i's genuine 0 for a path-executed program. The same idiom covered every sub-read of the
/// program bundle here: a refused <c>cnc_rdopmode</c> published Mode <c>0</c> (= MDI, a real
/// mode) and a refused <c>cnc_rdblkcount</c> a BlockCount of 0.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasProgramInfoQualityTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasResult<WireProgramName> Name(string name, int? oNumber) => new(0, new WireProgramName(name, oNumber));
[Fact]
public void A_payload_without_an_O_number_maps_to_null_not_zero()
{
var info = WireFocasClient.MapProgramInfo(Name("O1111", null), new(0, 42), new(0, 1));
info.Name.ShouldBe("O1111");
info.ONumber.ShouldBeNull();
info.BlockCount.ShouldBe(42);
info.Mode.ShouldBe(1);
}
[Fact]
public void A_genuine_zero_O_number_stays_zero()
{
// Live Z-34184: a 256-byte payload whose o_num is present and genuinely 0.
WireFocasClient.MapProgramInfo(Name("//CNC_MEM/USER/LIBRARY/O1111", 0), new(0, 0), new(0, 1))
.ONumber.ShouldBe(0);
}
[Fact]
public void Refused_sub_reads_map_to_null_not_a_fabricated_value()
{
var info = WireFocasClient.MapProgramInfo(Name("O1", 1), new FocasResult<int>(1, 0), new FocasResult<short>(1, 0));
info.BlockCount.ShouldBeNull();
info.Mode.ShouldBeNull("Mode 0 is MDI — a real mode, not 'unknown'");
var nameless = WireFocasClient.MapProgramInfo(new FocasResult<WireProgramName>(1, null), new(0, 3), new(0, 1));
nameless.Name.ShouldBeNull();
nameless.ONumber.ShouldBeNull();
}
[Fact]
public async Task A_missing_field_reads_BadNoData_while_its_siblings_stay_good()
{
var client = new FakeFocasClient { ProgramInfo = new FocasProgramInfo("O1111", null, 12, null) };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(10),
ProgramPollInterval = TimeSpan.FromMilliseconds(10),
},
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && drv.GetDeviceState(Host)?.LastProgramInfo is null)
await Task.Delay(15);
var read = await drv.ReadAsync(
[$"{Host}/Program/Name", $"{Host}/Program/BlockCount", $"{Host}/Program/ONumber",
$"{Host}/OperationMode/Mode", $"{Host}/OperationMode/ModeText"],
CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[0].Value.ShouldBe("O1111");
read[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[1].Value.ShouldBe(12);
read[2].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
read[2].Value.ShouldBeNull();
read[3].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
read[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// Offline byte-level coverage for the managed FOCAS/2 wire-protocol decode layer
/// (<see cref="FocasWireProtocol"/>) — the big-endian framing + block-envelope decode that
/// is this driver's analogue of P/Invoke struct marshalling. Before this suite the entire
/// wire decode surface (including the <c>cnc_getfigure</c> figure stride) ran without a
/// wire decode surface ran without a
/// single byte-level test; every other test drives the <c>IFocasClient</c> seam through the
/// Fake, which bypasses all framing. Recorded as Driver.FOCAS-013.
/// </summary>
@@ -200,29 +200,6 @@ public sealed class FocasWireProtocolTests
Should.Throw<FocasWireException>(() => FocasWireProtocol.ParseResponseBlocks(body));
}
// ---- The cnc_getfigure figure stride: decode a response block into shorts ----
[Fact]
public void ParseResponseBlocks_yields_a_payload_that_decodes_as_a_sequence_of_figure_shorts()
{
// cnc_getfigure (command 0x00d3) returns one big-endian short per axis. Build the
// response block and assert the payload decodes to the [3, 1, 0] figure sequence the
// driver's auto-scale path reads.
var figurePayload = new byte[6];
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(0, 2), 3);
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(2, 2), 1);
BinaryPrimitives.WriteInt16BigEndian(figurePayload.AsSpan(4, 2), 0);
var body = BuildResponseBody((command: (ushort)0x00d3, rc: (short)0, payload: figurePayload));
var block = FocasWireProtocol.ParseResponseBlocks(body).Single();
var figures = new List<int>();
for (var offset = 0; offset + 2 <= block.Payload.Length; offset += 2)
figures.Add(BinaryPrimitives.ReadInt16BigEndian(block.Payload.AsSpan(offset, 2)));
figures.ShouldBe(new[] { 3, 1, 0 });
}
// ---- ReadAscii / ReadNameRecord ----
[Fact]
@@ -239,11 +216,12 @@ public sealed class FocasWireProtocolTests
}
[Fact]
public void ReadNameRecord_reads_two_bytes_and_strips_padding()
public void ReadNameRecord_strips_padding()
{
// Full record semantics (first NUL, all four bytes) are pinned in FocasNameRecordTests.
FocasWireProtocol.ReadNameRecord("X \0\0"u8.ToArray()).ShouldBe("X");
FocasWireProtocol.ReadNameRecord("XY"u8.ToArray()).ShouldBe("XY");
FocasWireProtocol.ReadNameRecord(new byte[] { 0x58 }).ShouldBe(string.Empty); // < 2 bytes
FocasWireProtocol.ReadNameRecord(ReadOnlySpan<byte>.Empty).ShouldBe(string.Empty);
}
// ---- helpers ----
@@ -98,7 +98,7 @@ public sealed class FocasWireV3Tests
[Fact]
public void ParseServoMeters_uses_an_eight_byte_stride_and_names_from_the_axis_block()
{
// Three 8-byte records {int32 data; int16 dec=10; int16 unit=0}: data = 0, 50, -3.
// Three 8-byte records {int32 value; u16 base=10; u16 exponent=0}: value = 0, 50, -3.
byte[] sv =
[
0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, // axis 1: data=0
@@ -120,7 +120,8 @@ public sealed class FocasWireV3Tests
meters[0].Value.ShouldBe(0);
meters[1].Name.ShouldBe("Y");
meters[1].Value.ShouldBe(50); // 8-byte stride: a 12-byte stride would misread this as 655360
meters[1].Decimal.ShouldBe((short)10);
meters[1].Base.ShouldBe(10); // offset 4 is the envelope BASE, not a decimal count
meters[1].Scaled.ShouldBe(50.0);
meters[2].Name.ShouldBe("Z");
meters[2].Value.ShouldBe(-3);
}
@@ -0,0 +1,31 @@
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Loads the raw FOCAS response PDUs under <c>Fixtures/v3/</c> — byte-for-byte captures from a
/// real FANUC 31i-B (Makino, series G431) — and splits them into response blocks with the
/// production <see cref="FocasWireProtocol.ParseResponseBlocks"/>. The <c>live-*</c> captures
/// were taken by the sister ignitionoee FOCAS driver's read-only probes of Z-34184 (2026-08-12)
/// and are shared verbatim; the <c>ref-*</c> ones are this repo's own 2026-06-25 capture.
/// </summary>
internal static class WireFixtures
{
private const int PduHeaderLength = 10;
/// <summary>Every response block of the named capture, in wire order.</summary>
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
/// <returns>The parsed response blocks.</returns>
public static IReadOnlyList<ResponseBlock> Blocks(string name)
{
var raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "v3", name));
return FocasWireProtocol.ParseResponseBlocks(raw.AsSpan(PduHeaderLength));
}
/// <summary>The payload of the first block carrying <paramref name="command"/>.</summary>
/// <param name="name">File name under <c>Fixtures/v3/</c>.</param>
/// <param name="command">The FOCAS command id to locate.</param>
/// <returns>That block's payload bytes.</returns>
public static byte[] Payload(string name, ushort command) =>
Blocks(name).First(b => b.Command == command).Payload;
}
@@ -19,6 +19,11 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<!-- Real FANUC 31i-B response captures (raw PDUs); read by WireFixtures. -->
<None Include="Fixtures\v3\*.bin" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>