From 487d394cde079cd2a34246fc467c4c44148fce65 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 16:59:08 -0400 Subject: [PATCH] docs(focas): Status/ flags, PMC coalescing, parts count via PARAM:6711, read-only policy (#257 #266 #258) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- CLAUDE.md | 3 ++ docs/drivers/FOCAS.md | 77 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c68f757..2eb411c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1105,6 +1105,9 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types `BadNotSupported`). - **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown. +- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed); + adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range + falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only). - **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync` first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index f2442d1b..95355e3e 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -146,7 +146,7 @@ reference them will throw at startup with a message pointing here. | Capability | Wire path | Notes | |------------|-----------|-------| | `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally | -| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented | +| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented, and none will be (policy: the stranded write/unlock backlog items #268–#271 are won't-do; a write path on a production CNC is a safety decision, not a feature gap) | | `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device | | `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups | | `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` | @@ -224,15 +224,36 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as | `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick | | `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` | +| `Status/` | `Auto`, `Run`, `Motion`, `Mstb`, `Emergency`, `Alarm`, `Edit`, `TmMode` (raw FANUC codes, Int32) + `RunText` ("RESET", "STOP", "HOLD", "START", "MSTR") — see [CNC status flags](#cnc-status-flags-status) | `cnc_statinfo` (polled on `ProgramPollInterval`) | | `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 | +### CNC status flags (`Status/`) + +`cnc_statinfo` (the `ODBST` block) is exposed as read-only `FIXED:Status/` nodes (#257). Values are the +control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported: + +| Field | Codes | +|---|---| +| `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) | +| `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) | +| `Motion` | 0 none, 1 axis motion, 2 dwell | +| `Mstb` | 0 idle, 1 executing an M/S/T/B function | +| `Emergency` | 0 not, 1 emergency stop, 2 reset | +| `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) | +| `Edit`, `TmMode` | program-edit state; T/M mode | + +Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String). +Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no +`Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they +issue no wire traffic and go `BadCommunicationError` once the device has stopped answering. + ### Per-series node suppression The driver probes each optional call once at bootstrap. If the target CNC returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the corresponding subtree is **not emitted** — the operator doesn't see nodes that will only ever return `BadDeviceFailure`. Capability suppression -covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and +covers `Spindle/`, `Program/` + `OperationMode/`, `Status/`, `Timers/`, and per-axis `ServoLoad` independently. Identity + `Axes/*` position reads (which every Fanuc CNC supports) are always emitted. @@ -265,6 +286,54 @@ fallen back to `PositionDecimalPlaces` and published raw counts. numbers whose meaning is MTB-specific, and which no browse can discover. Those go under the device folder alongside the fixed subtree. +### Parts count (`PARAM:6711`) + +There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC parameter, so it +is already readable as a hand-authored `PARAM:` tag (Int32), and a fixed `Parts/` subtree would hard-code +parameter numbers that are control- and MTB-specific. On the standard FANUC 0i / 16i / 30i parameter map: + +| Address | Parameter | Meaning | +|---|---|---| +| `PARAM:6711` | No. 6711 | Number of parts machined (resets with the operator's clear) | +| `PARAM:6712` | No. 6712 | Total number of parts machined (cumulative) | +| `PARAM:6713` | No. 6713 | Number of parts required (the target) | + +The count-up trigger (which M code or cycle end increments it) is set by No. 6710 and bit 0 of No. 6700, +so confirm in the machine's parameter manual that the counter is actually enabled and that 6711–6713 mean +what the MTB says they mean before relying on them. Cycle time per part is not provided; the `Timers/` +subtree exposes `CycleSeconds` and a client can difference it across a `PARAM:6711` change. + +## PMC range coalescing + +A poll batch that reads several nearby PMC tags used to issue one `pmc_rdpmcrng` per tag. The driver now +merges tags of the same PMC letter and width that sit close together into **one** range read and slices each +tag's value out of the result (#266). Single, isolated PMC tags — the common "one tag per area" case — are +read exactly as before. + +```jsonc +"PmcCoalesce": { + "Enabled": true, // default + "MaxSpanBytes": 128, // hard cap on one merged range + "MaxGapBytes": 8 // bytes nobody asked for tolerated between two merged tags; 0 = only strictly adjacent +} +``` + +Rules worth knowing: + +- Tags merge only within the same letter and width class (Bit and Byte are one class; Word, Long, Float, Double + are separate). A multi-byte tag joins a range only when its offset from the range start is a multiple of its + width, because the CNC splits a range into width-sized slots from the start. +- **Spare gap bytes must be valid PMC addresses.** A gap that crosses a hole in the CNC's PMC map makes the CNC + refuse the whole range. That is handled, not fatal: a refused range is remembered per device and its tags are + read **individually**, each with its own true status (a refusal is never stamped onto healthy members). If + that happens routinely, lower `MaxGapBytes` (0 disables gap-spanning entirely) or set `Enabled` to `false`. +- A transport failure or timeout on the merged read fails every member with `BadCommunicationError` and does + **not** retry per tag — the link is down, so per-tag retries would only multiply timeouts. +- Read-only: this changes `pmc_rdpmcrng` reads only; nothing here can write. + +The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown +key edited through that form is dropped), so set it in the raw config. + ## Alarm projection Alarm surfacing is **disabled by default** because the polling cost is wasted @@ -324,7 +393,9 @@ latency spike once per cadence. driver surface via `FakeFocasClient`. Includes the alarm-projection raise / clear diffing tests. - **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` - hold the Docker simulator scaffold; see + hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent; + `Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and + `pmc` state); see [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md) for what the simulator emits vs. real CNC behaviour. - **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real