feat(focas): OtOpcUa-owned otopcua-focas-fixture stack (#651) + FOCAS backlog docs (#633)

Fixture renamed off ignitionoee's otopcua-focas-sim, port 8193 published, project label,
default endpoint 10.100.0.35:8193; infra/dev-environment docs. FOCAS.md: MACRO:3901 parts
count, PMC override + cycle-time recipes, won't-do/blocked backlog.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:12:13 -04:00
parent 8801631739
commit 7dd64b8ccd
9 changed files with 125 additions and 25 deletions
+1 -1
View File
@@ -510,7 +510,7 @@ These are consumed by the driver **integration-test fixtures** (under `tests/Dri
| `AB_SERVER_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs` | `10.100.0.35:44818` |
| `S7_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs` | `10.100.0.35:1102` (non-privileged; not S7-standard 102) |
| `OPCUA_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs` | `opc.tcp://10.100.0.35:50000` |
| `OTOPCUA_FOCAS_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs` | `localhost:8193` |
| `OTOPCUA_FOCAS_SIM_ENDPOINT` | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs` | `10.100.0.35:8193` |
> Additional harness/parity/soak env vars (`OTOPCUA_FOCAS_*`, `OTOPCUA_PARITY_*`, `OTOPCUA_SOAK_*`, `OTOPCUA_HARNESS_USE_SQL`) exist only in the test/parity/soak harnesses, not in production source, and are out of scope for this reference.
+3 -2
View File
@@ -52,8 +52,9 @@ message naming the CNC series + documented limit.
managed `FocasDriver` end-to-end. A single gate:
**Docker compose up** — tests skip when the TCP probe fails, with a
pointer to the compose command. The endpoint defaults to `localhost:8193`
and is overridable via `OTOPCUA_FOCAS_SIM_ENDPOINT`.
pointer to the compose command. The endpoint defaults to `10.100.0.35:8193`
(the OtOpcUa-owned `otopcua-focas-fixture` container, stack `/opt/otopcua-focas`; see
`infra/README.md`) and is overridable via `OTOPCUA_FOCAS_SIM_ENDPOINT`.
When the mock is up, `WireFocasClient` dials it over TCP exactly like a
real CNC, and the mock's native FOCAS Ethernet responder replies with
+91 -12
View File
@@ -286,22 +286,69 @@ 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`)
### Parts count (`MACRO:3901`, or `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:
There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC value, so it
is already readable as a hand-authored tag, and a fixed `Parts/` subtree would hard-code numbers that are
control- and MTB-specific. Two addresses carry it; **which one works depends on the control**:
| Address | Parameter | Meaning |
| Address | What | Use when |
|---|---|---|
| `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) |
| `MACRO:3901` | Custom macro system variable #3901 — parts machined (`#3902` = parts required) | **Recommended.** Works over the wire on a Makino 31i-B (Z-34184, G431), where `cnc_rdparam` is refused |
| `PARAM:6711` | CNC parameter No. 6711 — number of parts machined (`6712` total, `6713` required) | Controls that answer `cnc_rdparam` (standard FANUC 0i / 16i / 30i parameter map) |
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.
⚠️ **`PARAM:` reads do not work on the Makino.** Its 31i-B answers `cnc_rdparam` (0x000E) with `EW_FUNC`, so a
`PARAM:6711` tag reads `BadNotSupported` there. `cnc_rdmacro` is accepted, so `MACRO:3901` is the address to
author on that machine. Both are Int32 tags.
The count-up trigger (which M code or cycle end increments the counter) is set by No. 6710 and bit 0 of No.
6700, so confirm in the machine's manual that the counter is actually enabled before relying on either
address — a disabled counter reads a constant `0` (a *vacant* macro reads `BadNoData`, never `0`; see
[Wire decode corrections](#wire-decode-corrections-2026-10)). A `0` from `MACRO:3901` is therefore a valid
decode of "counter not counting", not a driver fault. Cycle time per part is not provided as a node — see the
[virtual-tag recipe](#recipe-cycle-time-per-part-virtual-tag-272).
### Recipe: feed / rapid / spindle override via PMC (#259)
The CNC's override values are ordinary PMC signals (the MTB ladder copies the operator-panel switches into the
G area), so they are readable today with plain PMC tags — read-only, no new driver surface. On the standard
FANUC signal map:
| Signal | PMC address | Notes |
|---|---|---|
| Feed override `*FV0-*FV7` | `G12` (Byte) | **Inverted**: the byte holds the one's complement. Percent = `255 - value` for a binary-coded switch (`255` = 0%, `155` = 100%); gray-coded/ladder-converted panels differ |
| Rapid override `ROV1`, `ROV2` | `G14.0`, `G14.1` (Bit) | Two bits select 100% / 50% / 25% / F0 |
| Spindle override `SOV0-SOV7` | `G30` (Byte) | Percent, usually a direct 0-150 value |
Author them as normal PMC tags (`G12`, `G14.0`, `G30` — G is accepted on the 30i family and the 16i). To publish a
percentage rather than the raw byte, derive it with a virtual tag (`255 - ctx.GetTag("{{equip}}/FeedOverrideRaw")`).
⚠️ **The addresses above are the FANUC *standard* signal assignment; the meaning is defined by the machine
builder's ladder.** Confirm each against the machine's own PMC signal list before trusting the number, and
validate with the panel switches at known positions (read-only observation — never write PMC). **Modal G/M/T
codes are not available**: `cnc_modal` has no known wire command id here (see
[Not ported](#not-ported-633-backlog)).
### Recipe: cycle time per part (virtual tag, #272)
A per-part cycle time is **not** a driver feature: `FIXED:Timers/CycleSeconds` and the parts count
(`MACRO:3901` / `PARAM:6711`) are both already readable, and the result is a pure function of them. Author the
count as an equipment tag named `PartsCount` and `FIXED:Timers/CycleSeconds` as `CycleSeconds`, then add a
virtual tag (Double, change-triggered by those two references):
```csharp
// Average cycle seconds per part since the counters were last cleared.
var parts = (double)ctx.GetTag("{{equip}}/PartsCount").Value;
var cycle = (double)ctx.GetTag("{{equip}}/CycleSeconds").Value;
return parts > 0 ? cycle / parts : null; // no parts yet: no average
```
This is a **running average**, valid only while both counters accumulate from the same reset (an operator clearing
one but not the other skews it until they are cleared together). The exact *last-cycle delta* (difference
`CycleSeconds` across a parts-count step) needs the script to remember the previous sample, and virtual-tag scripts
have no documented persistent state (see [`docs/ScriptEditor.md`](../ScriptEditor.md)) — compute that in the
consuming system (e.g. ScadaBridge) from the two raw tags instead. Both inputs hold while either is Bad, per the
quality contract.
## PMC range coalescing
@@ -448,6 +495,38 @@ re-interpretation of `cnc_rdparam` (no successful `cnc_rdparam` capture exists o
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).
## Not ported (#633 backlog)
The #607 porting backlog (#257-#272) was triaged against this read-only driver. Disposition:
**Done / documented:** #257 `Status/` fixed subtree, #258 parts count ([`MACRO:3901`](#parts-count-macro3901-or-param6711)),
#259 overrides ([PMC recipe](#recipe-feed--rapid--spindle-override-via-pmc-259)), #265 16i PMC F/G, #266 PMC range
coalescing, #272 ([virtual-tag recipe](#recipe-cycle-time-per-part-virtual-tag-272)).
**Won't do — the read-only policy (#268-#271).** The FOCAS driver is read-only by design
(`WireFocasClient.WriteAsync` returns `BadNotWritable`) and that is a safety decision for real CNCs, not a
gap: no write infrastructure / per-tag opt-in (#268), no `cnc_wrmacro` / `cnc_wrparam` (#269), no `pmc_wrpmcrng`
or PMC bit read-modify-write (#270), no password / unlock parameter (#271). Nothing in the driver or CLI should be
read as a path toward them.
**Blocked — need an authoritative wire capture first.** None of the following has a wire command id known to this
repo or to the sister ignitionoee client, and neither mock serves them over the binary wire:
| Item | Capability | Blocked by |
|---|---|---|
| #259 (modal part) | Modal G / M / T codes (`cnc_modal`) | no wire id |
| #260 | Tool number / tool life, work-coordinate and tool offsets (`cnc_rdtofs` / `rdzofs`) | no wire ids |
| #261 | Operator messages (`cnc_rdopmsg`) and the currently-executing block text (`cnc_rdexecprog`) | no wire ids |
| #263 | `DIAG:` address area (`cnc_diagnoss`) — the highest-value one: a single generic area would unlock many fields | no wire id |
| #264 | Multi-path / multi-channel (`cnc_getpath`) — the wire layer has `PathId` plumbing but no per-device/tag option | no wire id, and no multi-path control to validate against (the Makino is single-path) |
| #267 | Alarm history (`cnc_rdalmhistry`) | no wire id; the live alarm surface ([#647](#alarm-projection)) comes first |
⚠️ **Do not discover these ids by probing the production Makino.** An unknown or guessed command id could map to a
function that is not a read, and even known ids misbehave on the 31i-B (`cnc_rdparam` 0x000E answers `EW_FUNC`). The
0x00D3 `cnc_getfigure` lesson (see Wire decode corrections) already showed that an id agreed between two mocks can be
fiction. The prerequisite is an authoritative capture: real FWLIB on a Windows host against a **non-production**
control, or a passive capture of an existing FWLIB application's traffic.
## Further reading
- [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md) — full OPC UA node
+1 -1
View File
@@ -67,7 +67,7 @@ Running record of v2 dev services on the Windows dev VM. Updated on every instal
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box — full ArchestrA + Historian + OI-Server stack | v1 baseline | Local COM via MXAccess (`C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`); Historian via `aaH*` services; SuiteLink via `slssvc` | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ **Fully available — Phase 2 lift unblocked.** 27 ArchestrA / AVEVA / Wonderware services running incl. `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`, `AutoBuild_Service`; full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `aahSupervisor`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `InSQLManualStorage`, `InSQLSystemDriver`, `HistorianSearch-x64`); `slssvc` (Wonderware SuiteLink); `OI-Gateway` install present at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (decision #142 AppServer-via-OI-Gateway smoke test now also unblocked) |
| GLAuth (LDAP) | Docker container `zb-shared-glauth` on the Linux Docker host — managed via `scadaproj/infra/glauth/` | v2.4.0 | `10.100.0.35:3893` (LDAP plaintext; LDAPS disabled) | Bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`; baseDN `dc=zb,dc=local` | `scadaproj/infra/glauth/` (source of truth + deploy/verify runbook) | ✅ Running on Docker host. Shared across OtOpcUa, MxAccessGateway, ScadaBridge. OtOpcUa groups: `OtOpcUa-Admins`→Administrator, `OtOpcUa-Designers`→Designer, `OtOpcUa-Viewers`→Viewer. The per-VM NSSM service at `C:\publish\glauth\` and old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. |
| OPC Foundation reference server | Not yet built | — | `10.100.0.35:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
| FOCAS TCP stub | Not yet built | — | `10.100.0.35:8193` (target) | n/a | — | Pending (built in Phase 5; runs on Docker host) |
| FOCAS fixture (`otopcua-focas-fixture`, vendored focas-mock) | Docker compose at `/opt/otopcua-focas/` on Docker host (source: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/`) | vendored snapshot | `10.100.0.35:8193` (published; `OTOPCUA_FOCAS_SIM_ENDPOINT`) | n/a | n/a | Owned by OtOpcUa. NB `otopcua-focas-sim` on the same host is **ignitionoee's** mock (#651) — do not touch it |
| Modbus simulator (`otopcua-pymodbus:3.13.0`) | Docker compose at `/opt/otopcua-modbus/` on Docker host | pinned 3.13.0 | `10.100.0.35:5020` | n/a | n/a | Stack staged; bring up with `lmxopcua-fix up modbus <profile>` from this VM |
| AB CIP fixture (`otopcua-ab-server:libplctag-release`) | Docker compose at `/opt/otopcua-abcip/` on Docker host | source-pinned `release` tag | `10.100.0.35:44818` | n/a | n/a | Stack staged; bring up with `lmxopcua-fix up abcip <profile>` from this VM |
| S7 fixture (`otopcua-python-snap7:1.0`) | Docker compose at `/opt/otopcua-s7/` on Docker host | python-snap7 ≥2.0 | `10.100.0.35:1102` | n/a | n/a | Stack staged; bring up with `lmxopcua-fix up s7 s7_1500` from this VM |