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