diff --git a/docs/drivers/FOCAS-Test-Fixture.md b/docs/drivers/FOCAS-Test-Fixture.md
index 4cda662c..7875aedc 100644
--- a/docs/drivers/FOCAS-Test-Fixture.md
+++ b/docs/drivers/FOCAS-Test-Fixture.md
@@ -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
diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md
index caa1d29b..3df703f6 100644
--- a/docs/drivers/FOCAS.md
+++ b/docs/drivers/FOCAS.md
@@ -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
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
index da05b679..7929cf98 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs
@@ -132,12 +132,11 @@ public sealed class FocasAlarmProjectionOptions
/// default) to skip range validation and preserve legacy behaviour.
///
///
-/// Axis positions returned by cnc_rddynamic2 are scaled integers. The driver
-/// divides AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo by
-/// 10^PositionDecimalPlaces at the publish seam so they surface in engineering
-/// units on the Float64 axis nodes. Default 0 (no scaling) is byte-identical to
-/// legacy behaviour. Auto-fetching this via cnc_getfigure is deferred (wire-gated),
-/// so it is config-supplied. Negative values are clamped to 0 (no scaling).
+/// Fallback only. A real CNC's cnc_rddynamic2 position block carries its own
+/// scale (raw / base^exponent), 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 10^PositionDecimalPlaces. Default 0 (no scaling).
+/// Negative values are clamped to 0.
///
public sealed record FocasDeviceOptions(
string HostAddress,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FixedTreeNames.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FixedTreeNames.cs
index 2666310d..e614146e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FixedTreeNames.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FixedTreeNames.cs
@@ -41,11 +41,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// config redeploy).
///
///
-/// Suffix collisions with genuine names are structurally impossible for axes (FOCAS axis
-/// displays are at most two characters — name + suffix — so they cannot contain _),
-/// 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 _), 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.
+///
+///
+/// Note on the Makino that motivated this. Its two "AA" axes were a decode artefact:
+/// the name records actually read "AA6" and "AA7", and the wire layer read only
+/// the first two bytes of each 4-byte record. With the full record decoded
+/// () that machine reports unique names and
+/// its axes publish bare (Axes/AA6, Axes/AA7). This disambiguation remains as the
+/// guard for a control that genuinely reports duplicates.
///
///
internal static class FixedTreeNames
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index ea5acf3b..bbf4d0e9 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -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,
/// → path, which hits
/// and returns these cached values.
///
- 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;
}
///
- /// Resolve the position-scale factor (10^figure) for a single axis. Auto
- /// (cnc_getfigure) wins per-axis; manual PositionDecimalPlaces 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 ReadPositionFiguresAsync; 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
+ /// for that node only — never a Good number.
///
- 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;
+
+ /// Builds the snapshot for one cached double, mapping to BadNoData.
+ 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;
+ }
+
+ ///
+ /// Read and cache both spindle meters (load, motor speed) — each a cnc_rdspmeter
+ /// percentage of full scale (16384 = 100%) — for every spindle slot, as capability allows.
+ /// A slot whose record describes no value is cached as (BadNoData).
+ ///
+ 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 Axes,
IReadOnlyList Spindles,
- IReadOnlyList SpindleMaxRpms,
FocasFixedTreeCapabilities Capabilities)
{
///
@@ -1515,14 +1532,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
/// 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 cnc_rdspmaxrpm simply
- /// doesn't get a Spindle/{name}/MaxRpm node (instead of surfacing
+ /// node suppression so a 16i that doesn't export cnc_rdspmeter simply
+ /// doesn't get a Spindle/{name}/MotorSpeedPct node (instead of surfacing
/// BadDeviceFailure on every read).
///
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; }
///
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
- /// positions can carry the 10^PositionDecimalPlaces engineering-unit
- /// scale applied at ; integer fields
- /// (feed rate, spindle speed) widen to double on store.
+ /// positions can carry the engineering-unit scale applied at
+ /// ; integer fields (feed rate, spindle speed) widen
+ /// to double on store. (NaN) marks a field the CNC described as
+ /// holding no value.
///
public ConcurrentDictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Gets or sets the last program information snapshot.
@@ -1591,15 +1609,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public ConcurrentDictionary LastTimers { get; } = new();
/// Gets the last servo load percentages by servo name.
public ConcurrentDictionary LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
- /// Gets the last spindle load percentages by spindle index.
- public ConcurrentDictionary LastSpindleLoads { get; } = new();
///
- /// Gets or sets the per-axis position decimal-place figures fetched once at init via
- /// cnc_getfigure (parallel to the axis-name list; index = axis). An auto figure
- /// for an axis WINS over the configured PositionDecimalPlaces; 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;
+ /// for a slot the CNC described as holding none.
///
- public IReadOnlyList PositionFigures { get; set; } = [];
+ public ConcurrentDictionary LastSpindleLoads { get; } = new();
+ ///
+ /// Gets the last spindle motor-speed percentages (% of maximum speed) by spindle index;
+ /// for a slot the CNC described as holding none.
+ ///
+ public ConcurrentDictionary LastSpindleMotorSpeeds { get; } = new();
/// Disposes the FOCAS client instance.
public void DisposeClient()
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
index 8fbf4538..60a3a31f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -290,9 +290,9 @@ public static class FocasDriverFactoryExtensions
public string? Series { get; init; }
///
- /// Gets or sets the axis-position decimal places. cnc_rddynamic2 returns
- /// positions as scaled integers; the driver divides by 10^PositionDecimalPlaces
- /// so they surface in engineering units. Omitted / 0 = no scaling (legacy).
+ /// Gets or sets the fallback axis-position decimal places, applied only to a
+ /// cnc_rddynamic2 position block that carries no scale of its own (a real CNC's
+ /// block does, and its scale wins). Omitted / 0 = no scaling.
///
public int? PositionDecimalPlaces { get; init; }
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
index 33f65bba..90fdcbf3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs
@@ -19,6 +19,14 @@ public static class FocasStatusMapper
public const uint BadTimeout = 0x800A0000u;
public const uint BadTypeMismatch = 0x80740000u;
+ ///
+ /// 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 0.
+ ///
+ public const uint BadNoData = 0x809B0000u;
+
///
/// Map common FWLIB EW_* 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,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
index 3242223e..2d5d8725 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
@@ -140,9 +140,10 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.5 (servo meters) ----
///
- /// Read the servo-load meter percentages across all configured axes.
- /// Values are percentages (scaled by 10^Dec). Empty list on a
- /// disconnected session or unsupported CNC.
+ /// Read the servo-load meter percentages across all configured axes. Each record's own
+ /// value / base^exponent envelope is applied (servo loads are direct percentages);
+ /// a record whose envelope describes no value carries a null .
+ /// Empty list on a disconnected session or unsupported CNC.
///
/// The cancellation token.
/// The servo-load meter percentages across all configured axes.
@@ -151,36 +152,31 @@ public interface IFocasClient : IDisposable
// ---- Fixed-tree T3.6 (spindle meters) ----
///
- /// Read per-spindle load percentages. Result list index corresponds to
- /// spindle index from . 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 (cnc_rdspmeter type 0; the
+ /// raw meter is a 1/16384 fraction of full scale). Result list index corresponds to
+ /// spindle index from ; 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).
///
/// The cancellation token.
/// Per-spindle load percentages, indexed as per .
- Task> GetSpindleLoadsAsync(CancellationToken cancellationToken);
+ Task> GetSpindleLoadsAsync(CancellationToken cancellationToken);
///
- /// Read per-spindle maximum RPM values. Static configuration, fetched once at
- /// bootstrap. Index alignment as per .
+ /// Read per-spindle motor speed as a percentage of maximum speed (cnc_rdspmeter
+ /// type 1, historically labelled cnc_rdspmaxrpm). A live value, not a max-RPM
+ /// constant. Index alignment and null semantics as per .
///
/// The cancellation token.
- /// Per-spindle maximum RPM values, indexed as per .
- Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
-
- ///
- /// Read the per-axis position decimal-place figure via cnc_getfigure. The returned
- /// list is parallel to (index = axis). An EMPTY list means the
- /// CNC/backend does not report figures — the driver then falls back to the configured
- /// PositionDecimalPlaces. Values are clamped non-negative.
- ///
- /// The cancellation token.
- /// Per-axis position decimal-place figures; empty when the CNC/backend doesn't report them.
- Task> GetPositionFiguresAsync(CancellationToken cancellationToken);
+ /// Per-spindle motor-speed percentages, indexed as per .
+ Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
}
-/// One servo-meter entry — one axis's current load percentage.
-public sealed record FocasServoLoad(string AxisName, double LoadPercent);
+///
+/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
+/// described no usable value (never a fabricated 0).
+///
+public sealed record FocasServoLoad(string AxisName, double? LoadPercent);
/// Which cumulative counter reads.
public enum FocasTimerKind
@@ -238,22 +234,19 @@ public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix
}
}
-///
-/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
-/// divides by 10^DecimalPlaces to get the decimal value. DecimalPlaces is
-/// supplied by the caller — either via device config (PositionDecimalPlaces) or
-/// the per-axis (cnc_getfigure) path.
-///
///
/// 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 cnc_rdopmode — see .
+/// A null field means the CNC did not report it (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 0, which is a genuine O-number, block count, and mode (MDI).
///
public sealed record FocasProgramInfo(
- string Name,
- int ONumber,
- int BlockCount,
- int Mode);
+ string? Name,
+ int? ONumber,
+ int? BlockCount,
+ int? Mode);
/// Human-readable text for the integer.
public static class FocasOpMode
@@ -277,6 +270,10 @@ public static class FocasOpMode
};
}
+///
+/// Fast-poll bundle for one axis (cnc_rddynamic2). Each position carries its own scale
+/// (see ); the driver converts it to engineering units at publish.
+///
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);
+
+///
+/// One cnc_rddynamic2 position (command 0x0026) as the CNC reported it. The 8-byte
+/// wire block is self-describing — [0..3] i32 raw, [4..5] u16 base (2 or 10),
+/// [6..7] u16 exponent (0xFFFF = the slot holds no valid position) — and the real
+/// coordinate is raw / base^exponent. Confirmed on a real FANUC 31i-B: every block of the
+/// ref-dynamic2-axis1.bin capture reports base 10 / exponent 4 (280.1574 mm, not
+/// 2801574). That envelope is why no separate figure read exists: the cnc_getfigure
+/// (0x00D3) this driver used to send is not a FOCAS wire command, and the 31i-B refuses it.
+///
+/// The raw i32 position value.
+/// The block's scale base (only 2 and 10 are meaningful).
+/// The block's scale exponent.
+///
+/// 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 PositionDecimalPlaces.
+///
+public readonly record struct FocasPosition(int Raw, int Base = 0, int Exponent = 0, bool HasScale = false)
+{
+ ///
+ /// False when the block says it holds no usable position: the 0xFFFF 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.
+ ///
+ ///
+ /// The exponent bound matters: left unbounded, raw / 10^400 is
+ /// raw / Infinity = 0.0 — a Good reading of a machine parked at the origin.
+ ///
+ public bool IsValid => !HasScale || Wire.FocasValueEnvelope.IsValid(Base, Exponent);
+
+ ///
+ /// The position in engineering units: Raw / Base^Exponent when the block carried its
+ /// own scale (the configured fallback is then ignored), else Raw / 10^fallbackDecimalPlaces.
+ /// Null when is false.
+ ///
+ /// The device's configured PositionDecimalPlaces.
+ /// The scaled position, or null when the block describes none.
+ 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;
+ }
+
+ /// A bare integer is a scale-less position (legacy shape; tests and fakes).
+ /// The raw position value.
+ public static implicit operator FocasPosition(int raw) => new(raw);
+}
///
/// One active alarm surfaced by . Shape
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
index 66010716..e2a3f759 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
@@ -99,16 +99,12 @@ public sealed class SynchronizedFocasClient : IFocasClient
RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken);
///
- public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
+ public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken);
///
- public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
- RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken);
-
- ///
- public Task> GetPositionFiguresAsync(CancellationToken cancellationToken) =>
- RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(ct), cancellationToken);
+ public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
+ RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
/// Disposes the inner client and the call gate.
public void Dispose()
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
index 02dce813..097d210c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
@@ -339,21 +339,59 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
var rc = AggregateRc(blocks);
if (rc != 0) return new FocasResult(rc, null);
+ return new FocasResult(rc, ParseDynamic2(blocks));
+ }
+
+ ///
+ /// Aggregate the cnc_rddynamic2 response blocks of one axis read into a
+ /// . The four 0x0026 blocks are taken in request order
+ /// (absolute / machine / relative / distance-to-go), each decoded with its own scale by
+ /// .
+ ///
+ /// The response blocks of one request.
+ /// The decoded fast-poll bundle.
+ internal static WireDynamic ParseDynamic2(IReadOnlyList blocks)
+ {
var programPayload = FindPayload(blocks, 0x001c);
- return new FocasResult(
- 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)));
+ }
+
+ ///
+ /// Decode one cnc_rddynamic2 position block (0x0026). The 8-byte block is
+ /// self-describing — [0..3] i32 raw, [4..5] u16 base, [6..7] u16 exponent,
+ /// value = raw / base^exponent — pinned on a real FANUC 31i-B (every block of
+ /// ref-dynamic2-axis1.bin reports base 10 / exponent 4). A 4..7-byte block carries no
+ /// envelope and is returned scale-less (the driver then applies the configured
+ /// PositionDecimalPlaces); a shorter one decodes as a scale-less 0, as before.
+ ///
+ ///
+ /// Validity (vacant 0xFFFF exponent, a base other than 2/10, an absurd exponent) is a
+ /// per-node quality decision made by , not a fault here:
+ /// raising would let one empty slot take out the whole poll. Ported from the sister
+ /// ignitionoee driver v0.17.0 (Dynamic2.scaledPosition, Focas.Wire-014).
+ ///
+ /// The 0x0026 response-block payload.
+ /// The position with whatever scale the block carried.
+ 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);
}
/// Read servo-meter load percentages via cnc_rdsvmeter (command 0x0056).
@@ -383,47 +421,14 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
}
///
- /// Read the per-axis position decimal-place figures via cnc_getfigure
- /// (command 0x00d3). The response payload is a self-delimiting sequence of
- /// big-endian short decimal-place counts — one per configured axis — which the
- /// driver applies as a 10^figure scale on raw position reads.
+ /// Read the per-spindle load meter via cnc_rdspmeter (command 0x0040, type 0).
+ /// is % of rated load (16384 = 100%).
///
- ///
- /// The 0x00d3 command id is co-designed with the in-tree focas_mock 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).
- ///
- /// Cancellation token for the read operation.
- /// Optional per-call timeout override.
- /// Optional path ID override; defaults to .
- /// The per-axis position decimal-place figures, wrapped in a .
- public async Task>> 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>(block.Rc, null);
-
- var payload = block.Payload;
- var figures = new List(payload.Length / 2);
- for (var offset = 0; offset + 2 <= payload.Length; offset += 2)
- figures.Add(ReadInt16(payload, offset));
-
- return new FocasResult>(block.Rc, figures);
- }
-
- /// Read per-spindle load percentages via cnc_rdspload (command 0x0040 with arg1=0).
/// Spindle selector; -1 selects all spindles.
/// Cancellation token for the read operation.
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
- /// The per-spindle load percentages, wrapped in a .
+ /// The per-spindle load meters, wrapped in a .
public Task>> 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);
- /// Read per-spindle maximum RPMs via cnc_rdspmaxrpm (command 0x0040 with arg1=1).
+ ///
+ /// Read the per-spindle motor-speed meter via cnc_rdspmeter (command 0x0040,
+ /// type 1 — historically labelled cnc_rdspmaxrpm). It is NOT a maximum-RPM constant:
+ /// it is the live motor speed as a fraction of maximum,
+ /// (a regression against cnc_rddynamic2 spindle speed on a real 31i-B gives
+ /// rpm = 1.220749 × meter, i.e. 16384 = the spindle's 20,000 rpm maximum).
+ ///
/// Spindle selector; -1 selects all spindles.
/// Cancellation token for the read operation.
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
- /// The per-spindle maximum RPMs, wrapped in a .
- public Task>> ReadSpindleMaxRpmAsync(
+ /// The per-spindle motor-speed meters, wrapped in a .
+ public Task>> 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);
+ ///
+ /// Decode a cnc_rdmacro (0x0015) payload. The on-wire ODBM record is 8
+ /// big-endian bytes: [0..3] i32 mantissa, [4..5] i16 length field (a
+ /// constant 10 on every observed response), [6..7] i16 dec_val
+ /// (0xFFFF = vacant).
+ ///
+ ///
+ /// 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 #500 = 1.0 published 0.01). 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
+ /// dec_val and is refused rather than guessed at.
+ ///
+ /// The macro number the request asked for.
+ /// The response-block payload.
+ /// The decoded macro.
+ /// The payload is shorter than 8 bytes.
+ internal static WireMacro ParseMacro(short number, byte[] payload)
+ {
+ RequireLength(payload, 8, "cnc_rdmacro");
+ return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
+ }
+
///
/// Read a PMC range via pmc_rdpmcrng. is the numeric
/// address-letter code (see );
@@ -745,13 +780,27 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
callTimeout.Token,
new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
- return ToResult>(block, payload =>
- {
- var values = new List();
- 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);
+ }
+
+ ///
+ /// Decode a cnc_rdspmeter (0x0040) payload: 8-byte records of
+ /// [+0..3] i32 value, [+4..5] u16 base, [+6..7] u16 exponent, one per
+ /// slot of a fixed multi-slot buffer (slots past the spindle count are residue and decode
+ /// as = false). Every slot is returned, in wire order.
+ ///
+ /// The response-block payload.
+ /// The per-slot meters.
+ internal static IReadOnlyList ParseSpindleMetrics(byte[] payload)
+ {
+ var values = new List();
+ 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> ReadSingleAsync(
@@ -932,18 +981,16 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
payload.Length >= 8 ? BinaryPrimitives.ReadInt32LittleEndian(payload.AsSpan(4, 4)) : 0);
///
- /// Decode a cnc_rdsvmeter response into records. On the
- /// 31i-B (v3) each per-axis LOADELM is 8 bytes — {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 cnc_rdaxisname (0x0089) block requested alongside it and are correlated
- /// by index. Validated against a live 31i-B 2026-06-25.
- /// Scaling caveat: downstream applies LoadPercent = data / 10^dec; on the 31i-B
- /// dec 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.
+ /// Decode a cnc_rdsvmeter response into records. Each
+ /// per-axis record is 8 bytes — [+0..3] i32 value, [+4..5] u16 base,
+ /// [+6..7] u16 exponent — the same self-describing envelope as the position blocks,
+ /// so the load is value / base^exponent, 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
+ /// cnc_rdaxisname (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 = false.
///
/// The raw cnc_rdsvmeter response payload.
/// The raw cnc_rdaxisname response payload used to correlate axis names by index.
@@ -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 blocks, ushort command, int selectorIndex)
+ private static FocasPosition ReadSelectorPosition(IReadOnlyList 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 ReadNameRecords(byte[] payload, short maxCount, Func factory)
+ ///
+ /// Decode an axis-name (0x0089) or spindle-name (0x008A) payload: an array of 4-byte records,
+ /// each named by . Blank records are skipped but
+ /// keep their slot's 1-based index.
+ ///
+ /// The record type the factory builds.
+ /// The response-block payload.
+ /// Maximum number of records to inspect.
+ /// Builds one record from its 1-based index and name.
+ /// The non-blank records, in wire order.
+ internal static IReadOnlyList ReadNameRecords(byte[] payload, short maxCount, Func factory)
{
var names = new List();
for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
index abdc1d74..844ea542 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
@@ -35,12 +35,15 @@ public sealed record WireStatus(
short Edit,
short TmMode);
-/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.
+///
+/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go,
+/// each carrying the scale its own 0x0026 block reported.
+///
public sealed record WireAxisPosition(
- int Absolute,
- int Machine,
- int Relative,
- int Distance);
+ FocasPosition Absolute,
+ FocasPosition Machine,
+ FocasPosition Relative,
+ FocasPosition Distance);
///
/// Fast-poll bundle for one axis from cnc_rddynamic2 — alarm flags, active program
@@ -56,18 +59,104 @@ public sealed record WireDynamic(
int SpindleSpeed,
WireAxisPosition Axis);
-/// One servo-meter entry from cnc_rdsvmeter — per-axis load percentage (scale by 10^).
+///
+/// The self-describing (value, base, exponent) envelope shared by every value record on
+/// the FOCAS wire — cnc_rddynamic2 positions, servo meters, spindle meters:
+/// scaled = value / base^exponent, where only base 2 and base 10 are meaningful.
+///
+internal static class FocasValueEnvelope
+{
+ ///
+ /// Largest exponent that can describe a real value. An i32 carries at most ten digits, so
+ /// anything beyond is a misread record — and value / 10^400 would be
+ /// value / Infinity = 0.0, a plausible Good zero.
+ ///
+ public const int MaxExponent = 18;
+
+ ///
+ /// 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 0xFFFF vacant marker).
+ ///
+ /// The record's base field.
+ /// The record's exponent field.
+ /// Whether the record may be published.
+ public static bool IsValid(int @base, int exponent) => exponent <= MaxExponent && @base is 2 or 10;
+
+ /// value / base^exponent, or null when is false.
+ /// The raw value.
+ /// The record's base field.
+ /// The record's exponent field.
+ /// The scaled value, or null.
+ public static double? Scale(int value, int @base, int exponent) =>
+ !IsValid(@base, exponent) ? null : exponent == 0 ? value : value / Math.Pow(@base, exponent);
+}
+
+///
+/// One servo-meter entry from cnc_rdsvmeter (0x0056): one axis's load. The 8-byte record
+/// is [+0..3] i32 value, [+4..5] u16 base, [+6..7] u16 exponent and the load
+/// is a direct percentage, = value / base^exponent.
+///
+///
+/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the base — 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).
+///
+/// 1-based axis slot.
+/// Axis name from the companion 0x0089 block (empty when none).
+/// Raw load value.
+/// Scale base (2 or 10 when meaningful).
+/// Scale exponent.
public sealed record WireServoMeter(
short Index,
string Name,
int Value,
- short Decimal,
- short Unit);
+ int Base,
+ int Exponent)
+{
+ /// False for a record whose envelope describes no usable value (buffer residue).
+ public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
-/// One spindle metric slot from cnc_rdspload / cnc_rdspmaxrpm.
+ /// The load percentage, Value / Base^Exponent; null when is false.
+ public double? Scaled => FocasValueEnvelope.Scale(Value, Base, Exponent);
+}
+
+///
+/// One spindle-meter slot from cnc_rdspmeter (0x0040; type 0 = load, type 1 = motor
+/// speed). Same 8-byte envelope as , but the scaled value is a
+/// fraction of full scale in 1/16384 units — see .
+///
+/// 1-based spindle slot.
+/// Raw meter value, in 1/16384-of-full-scale units.
+/// Scale base.
+/// Scale exponent.
public sealed record WireSpindleMetric(
short Index,
- int Value);
+ int Value,
+ int Base,
+ int Exponent)
+{
+ ///
+ /// The meter's full-scale count: 16384 = 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 cnc_rddynamic2 spindle speed over 596–20,000 rpm:
+ /// rpm = 1.220749 × meter, 16384 × 1.220749 = 20000.75 (Focas.Wire-019).
+ ///
+ public const int FullScale = 1 << 14;
+
+ /// False for a record whose envelope describes no usable value (buffer residue).
+ public bool IsValid => FocasValueEnvelope.IsValid(Base, Exponent);
+
+ ///
+ /// The meter as a percentage — (Value / Base^Exponent) × 100 / 16384 — or null when
+ /// is false. Values above 100 are real (short-term overload; the i16
+ /// record clamps one count short of 200%).
+ ///
+ public double? Percent => FocasValueEnvelope.Scale(Value, Base, Exponent) is { } scaled
+ ? scaled * 100.0 / FullScale
+ : null;
+}
///
/// One axis-name slot from cnc_rdaxisname. is the 1-based
@@ -86,13 +175,31 @@ public sealed record WireParameter(
int Value);
///
-/// Macro variable from cnc_rdmacro. Scaled decimal: the callable value is
-/// Value / 10^Decimal.
+/// Macro variable from cnc_rdmacro. Scaled decimal: the real value is
+/// Value / 10^Decimal (). FANUC normalises every macro to a
+/// 9-significant-digit mantissa, so alone is never a value to publish
+/// (live: #502 = 270 arrives as 270000000 / 10^6).
///
+/// Macro variable number (the request argument; not carried in the payload).
+/// The 9-significant-digit mantissa.
+/// Implied decimal places; negative (0xFFFF on the wire) = vacant.
public sealed record WireMacro(
short Number,
int Value,
- short Decimal);
+ short Decimal)
+{
+ ///
+ /// True when the CNC reported the variable as vacant (undefined / never set):
+ /// dec_val == 0xFFFF. A vacant macro's mantissa is 0, indistinguishable from a
+ /// genuine 0, so this must be tested before means anything. Any
+ /// negative decimal is treated as vacant — no other negative has ever been observed and
+ /// failing closed is the safe reading.
+ ///
+ public bool IsVacant => Decimal < 0;
+
+ /// The real value, Value / 10^Decimal; null when .
+ public double? Scaled => IsVacant ? null : Decimal == 0 ? Value : Value / Math.Pow(10.0, Decimal);
+}
/// PMC range read-back from pmc_rdpmcrng: one or more values of the requested width.
public sealed record WirePmcRange(
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs
index 35df33d3..2c6b394e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs
@@ -239,19 +239,34 @@ internal static class FocasWireProtocol
}
///
- /// 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 "X " becomes
- /// "X".
+ /// 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 — "X \0\0" → "X",
+ /// "AA6\0" → "AA6", "S1\0N" → "S1".
///
- /// The bytes to decode.
- /// The decoded name record.
+ ///
+ /// All four bytes are name bytes. This used to read only the first two, which
+ /// collapsed the real 31i-B's paired axes "AA6"/"AA7" (live capture
+ /// ref-axisname-0x0089.bin) to two axes both called "AA" — the duplicate Gitea
+ /// #548 then had to disambiguate as AA_1/AA_2.
+ /// The first NUL terminates, not trailing-NUL trimming. A control writes only the
+ /// bytes its name needs and leaves the rest of the buffer as it found it: Makino Z-34184's
+ /// cnc_rdspdlname answered 53 31 00 4e — "S1" plus the 'N' of an
+ /// earlier "CNC" response. Both rules are ported from the sister ignitionoee FOCAS
+ /// driver (Focas.Wire-001 / Focas.Wire-022).
+ ///
+ /// The record bytes (4 on the wire; a shorter span is read as far as it goes).
+ /// The decoded name; empty for a blank record.
public static string ReadNameRecord(ReadOnlySpan 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(' ');
}
+ /// Byte length of one axis/spindle name record.
+ public const int NameRecordLength = 4;
+
private static byte[] BuildRequestBlock(RequestBlock request)
{
var extra = request.ExtraPayload ?? Array.Empty();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
index bea99a40..acc104c1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
@@ -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);
+ }
+
+ ///
+ /// Combine the three program sub-reads into a , leaving any
+ /// field the CNC did not report null. A refused read, or an cnc_exeprgname2 payload
+ /// too short to carry the trailing O-number, used to publish a fabricated 0 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).
+ ///
+ /// The cnc_exeprgname2 result.
+ /// The cnc_rdblkcount result.
+ /// The cnc_rdopmode result.
+ /// The snapshot, null where the CNC did not say.
+ internal static FocasProgramInfo MapProgramInfo(
+ FocasResult name, FocasResult blockCount, FocasResult 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);
}
///
@@ -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();
}
///
- public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
+ public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
///
- public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
- ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
+ public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
+ ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMotorSpeedAsync(sel, ct), cancellationToken);
- ///
- // 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> 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> ReadSpindleMetricAsync(
+ ///
+ /// One cnc_rdspmeter 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.)
+ ///
+ private static async Task> ReadSpindleMetricAsync(
Func>>> call,
CancellationToken cancellationToken)
{
var result = await call(-1, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
- var list = new List();
- 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();
}
/// Disposes the wire client and releases all resources.
@@ -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
}
}
+ ///
+ /// Turn a decoded macro into the value + quality its tag publishes.
+ ///
+ ///
+ /// A vacant variable has no value at all → ,
+ /// never a Good 0 (its mantissa is 0, indistinguishable from a genuine zero).
+ /// Float tags get mantissa / 10^decimal. Integer tags (Byte / Int16 /
+ /// Int32 / Bit) get the exact whole value, tested with integer arithmetic
+ /// (mantissa % 10^decimal) so no epsilon is involved; a fractional macro on an
+ /// integer tag is — truncating or rounding
+ /// would publish a plausible wrong count at Good quality — and a whole value that does not
+ /// fit the type is . Ported from the sister
+ /// ignitionoee driver's macroInteger/macroReal (Focas-017/018).
+ ///
+ /// The decoded macro.
+ /// The tag's declared data type.
+ /// Optional bit index for a tag.
+ /// The boxed value (null when Bad) and its status.
+ 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);
+ }
+ }
+
+ /// Exact mantissa / 10^decimal when it is a whole number; integer arithmetic only.
+ 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)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md
index 701c4cc6..35907315 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md
@@ -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.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py
index 45857ac1..4a2430d0 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/defaults.py
@@ -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": {
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py
index 827355ea..52e4ff05 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py
@@ -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)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs
index f09d7e39..cc37571c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs
@@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests
}
///
- /// Verifies that per-axis position figures fetched via cnc_getfigure (wire command
- /// 0x00D3) scale the published AbsolutePosition, and that the auto figure wins over
- /// the configured PositionDecimalPlaces 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 cnc_rddynamic2 position block
+ /// (base 10 / exponent) scales the published AbsolutePosition, and that it wins over
+ /// the configured PositionDecimalPlaces fallback knob. (The mock refuses the old
+ /// cnc_getfigure 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.
///
[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);
}
}
+ ///
+ /// "The control described no value" reads Bad over the real wire, never a Good number:
+ /// a vacant 0x0026 position block (exponent 0xFFFF) Bads only its own axis
+ /// node, an unset macro (vacant dec_val) 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.
+ ///
+ [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
+ {
+ ["X"] = new { exponent = 0xFFFF }, // vacant-slot marker
+ ["Y"] = 3,
+ },
+ macros = new Dictionary
+ {
+ ["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 pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs
index 6eff4b42..19ef82d0 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs
@@ -210,9 +210,13 @@ public sealed class WireBackendTests
}
}
- /// Verifies that spindle load and max RPM data populate via the wire backend.
+ ///
+ /// 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%.
+ ///
[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);
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
index a488df99..ce8671e6 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
@@ -140,25 +140,18 @@ internal class FakeFocasClient : IFocasClient
public virtual Task> GetServoLoadsAsync(CancellationToken ct) =>
Task.FromResult>([.. ServoLoads]);
- /// Gets the list of spindle loads.
- public List SpindleLoads { get; } = [];
- /// Gets the list of spindle maximum RPMs.
- public List SpindleMaxRpms { get; } = [];
+ /// Gets the list of spindle load percentages (null = undescribable slot).
+ public List SpindleLoads { get; } = [];
+ /// Gets the list of spindle motor-speed percentages (null = undescribable slot).
+ public List SpindleMotorSpeeds { get; } = [];
/// Gets spindle loads asynchronously.
/// The cancellation token.
- public virtual Task> GetSpindleLoadsAsync(CancellationToken ct) =>
- Task.FromResult>([.. SpindleLoads]);
- /// Gets spindle maximum RPMs asynchronously.
+ public virtual Task> GetSpindleLoadsAsync(CancellationToken ct) =>
+ Task.FromResult>([.. SpindleLoads]);
+ /// Gets spindle motor-speed percentages asynchronously.
/// The cancellation token.
- public virtual Task> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
- Task.FromResult>([.. SpindleMaxRpms]);
-
- /// Gets or sets the per-axis position decimal-place figures returned by .
- public IReadOnlyList PositionFigures { get; set; } = [];
- /// Gets the per-axis position decimal-place figures asynchronously.
- /// The cancellation token.
- public virtual Task> GetPositionFiguresAsync(CancellationToken ct) =>
- Task.FromResult(PositionFigures);
+ public virtual Task> GetSpindleMotorSpeedsAsync(CancellationToken ct) =>
+ Task.FromResult>([.. SpindleMotorSpeeds]);
/// Disposes the client.
public virtual void Dispose()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis1-0x0026.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis1-0x0026.bin
new file mode 100644
index 00000000..7f51296a
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis1-0x0026.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis2-0x0026.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis2-0x0026.bin
new file mode 100644
index 00000000..cc7308a8
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-dynamic2-axis2-0x0026.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-load-0x0040.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-load-0x0040.bin
new file mode 100644
index 00000000..3d98bc57
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-load-0x0040.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-maxrpm-0x0040.bin b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-maxrpm-0x0040.bin
new file mode 100644
index 00000000..3a4a2415
Binary files /dev/null and b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/Fixtures/v3/live-spmeter-maxrpm-0x0040.bin differ
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs
index 87bc65f4..a2d1c33b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDuplicateSiblingNameTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMacroDecodeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMacroDecodeTests.cs
new file mode 100644
index 00000000..15bcdaab
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMacroDecodeTests.cs
@@ -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;
+
+///
+/// cnc_rdmacro (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).
+///
+///
+/// The on-wire ODBM payload is 8 bytes: [0..3] i32 mantissa,
+/// [4..5] i16 — a constant 10 on every observed response (a length field, NOT the
+/// decimal), and [6..7] i16 dec_val (0xFFFF = vacant). Reading the decimal at
+/// offset 4 decodes every macro on every control without ever failing — just wrongly: the
+/// captured #500 = 1.0 (05 f5 e1 00 00 0a 00 08) read as 100000000 / 10^10 =
+/// 0.01. 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.
+///
+[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(() => 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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterDecodeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterDecodeTests.cs
new file mode 100644
index 00000000..ef0cd867
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterDecodeTests.cs
@@ -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;
+
+///
+/// Servo (cnc_rdsvmeter, 0x0056) and spindle (cnc_rdspmeter, 0x0040) meter decode.
+/// Both are arrays of 8-byte records carrying the same self-describing envelope as every other
+/// value block on this wire — [+0..3] i32 value, [+4..5] u16 base (2 or 10),
+/// [+6..7] u16 exponent — so scaled = value / base^exponent.
+/// Ported from the sister ignitionoee FOCAS driver v0.17.2 / v0.17.3 (Focas.Wire-019).
+///
+///
+/// Servo: offset 4 is the BASE, not a decimal count. It holds a steady 10, so the
+/// old value / 10^decimal 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
+/// direct percentages (the live capture shows −57..227).
+/// Spindle: a fraction of full scale in 1/16384 units. Settled on the real Z-34184
+/// by regressing the type-1 meter against the independently read cnc_rddynamic2 spindle
+/// speed over 596–20,000 rpm: rpm = 1.220749 × meter, and 16384 × 1.220749 =
+/// 20000.75 — full scale is the spindle's 20,000 rpm maximum. So the type-1 meter
+/// (cnc_rdspmaxrpm by name) is the motor speed as a % of maximum, 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).
+///
+[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(), 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(), 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);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.cs
new file mode 100644
index 00000000..3294aa8f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasMeterPublishTests.cs
@@ -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;
+
+///
+/// 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 MotorSpeedPct — not the bootstrap-time
+/// MaxRpm constant it was mislabelled as.
+///
+[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 ReadSettledAsync(FocasDriver drv, string reference, Func 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 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();
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasNameRecordTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasNameRecordTests.cs
new file mode 100644
index 00000000..9e0a6be1
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasNameRecordTests.cs
@@ -0,0 +1,78 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// 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).
+///
+///
+/// Reading only 2 bytes collides real names. The live 31i-B capture
+/// (ref-axisname-0x0089.bin) carries "AA6" and "AA7"; the old 2-byte read
+/// turned both into "AA", which is exactly the "one observed Makino returns two axis
+/// folders both called AA" that Gitea #548 then had to disambiguate as AA_1/AA_2.
+/// Trailing-only trimming keeps buffer residue. A control writes only the bytes the
+/// name needs; on Makino Z-34184 (2026-08-19) cnc_rdspdlname came back
+/// 53 31 00 4e — "S1" then the 'N' of an earlier "CNC". The name
+/// ends at the first NUL.
+///
+[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"]);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionAutoScaleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionAutoScaleTests.cs
deleted file mode 100644
index 9c698a6b..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPositionAutoScaleTests.cs
+++ /dev/null
@@ -1,164 +0,0 @@
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
-
-///
-/// Phase 4b — per-axis auto-scale from cnc_getfigure figures. The driver fetches
-/// per-axis position decimal-place figures at init and applies them at the
-/// publish seam. Precedence: an auto figure for an axis WINS;
-/// the configured PositionDecimalPlaces 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.
-///
-[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 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 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
@@ -200,29 +200,6 @@ public sealed class FocasWireProtocolTests
Should.Throw(() => 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();
- 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.Empty).ShouldBe(string.Empty);
}
// ---- helpers ----
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs
index d3866896..c0b6c638 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWireV3Tests.cs
@@ -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);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/WireFixtures.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/WireFixtures.cs
new file mode 100644
index 00000000..0960bd37
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/WireFixtures.cs
@@ -0,0 +1,31 @@
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// Loads the raw FOCAS response PDUs under Fixtures/v3/ — byte-for-byte captures from a
+/// real FANUC 31i-B (Makino, series G431) — and splits them into response blocks with the
+/// production . The live-* captures
+/// were taken by the sister ignitionoee FOCAS driver's read-only probes of Z-34184 (2026-08-12)
+/// and are shared verbatim; the ref-* ones are this repo's own 2026-06-25 capture.
+///
+internal static class WireFixtures
+{
+ private const int PduHeaderLength = 10;
+
+ /// Every response block of the named capture, in wire order.
+ /// File name under Fixtures/v3/.
+ /// The parsed response blocks.
+ public static IReadOnlyList Blocks(string name)
+ {
+ var raw = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "v3", name));
+ return FocasWireProtocol.ParseResponseBlocks(raw.AsSpan(PduHeaderLength));
+ }
+
+ /// The payload of the first block carrying .
+ /// File name under Fixtures/v3/.
+ /// The FOCAS command id to locate.
+ /// That block's payload bytes.
+ public static byte[] Payload(string name, ushort command) =>
+ Blocks(name).First(b => b.Command == command).Payload;
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj
index 5d596302..4a98f116 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj
@@ -19,6 +19,11 @@
+
+
+
+
+