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
This commit is contained in:
Joseph Doherty
2026-10-08 16:59:08 -04:00
parent 8d916f2af7
commit 487d394cde
2 changed files with 77 additions and 3 deletions
+74 -3
View File
@@ -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/<field>` nodes (#257). Values are the
control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported:
| Field | Codes |
|---|---|
| `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) |
| `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) |
| `Motion` | 0 none, 1 axis motion, 2 dwell |
| `Mstb` | 0 idle, 1 executing an M/S/T/B function |
| `Emergency` | 0 not, 1 emergency stop, 2 reset |
| `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) |
| `Edit`, `TmMode` | program-edit state; T/M mode |
Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String).
Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no
`Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they
issue no wire traffic and go `BadCommunicationError` once the device has stopped answering.
### Per-series node suppression
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