Files
lmxopcua/docs/plans/focas-plan.md
Joseph Doherty 2d07d716dc Recover stashed driver-gaps work from pre-v2-mxgw-merge working tree
Captures uncommitted work that lived in the working tree on
v2-mxgw-integration but was orthogonal to the migration. Stashed
during the v2-mxgw merge to master (2026-04-30) and replanted here on
a feature branch off master so it's git-visible rather than living in
the stash list.

Two distinct buckets:

1. Tracked fixture/config refinements (10 files, ~36 lines):
   - scripts/e2e/test-opcuaclient.ps1
   - src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
   - 5 docker-compose.yml under tests/.../IntegrationTests/Docker/
     (AbCip, Modbus, OpcUaClient, S7)
   - 4 fixture .cs files (AbServerFixture, ModbusSimulatorFixture,
     OpcPlcFixture, Snap7ServerFixture)

2. Untracked driver-gaps queue artifacts (~8000 lines):
   - docs/plans/{abcip,ablegacy,focas,opcuaclient,s7,twincat}-plan.md
     — per-driver gap plans
   - docs/featuregaps.md — cross-cutting analysis
   - docs/v2/focas-deployment.md, docs/v2/implementation/focas-simulator-plan.md
   - followup.md — auto/driver-gaps queue follow-ups
   - scripts/queue/ — PR-queue automation tooling (12 files including
     pr-manifest.yaml at 1473 lines)

This commit is a snapshot for recoverability — review and split into
focused PRs (or discard) before merging anywhere downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:28:01 -04:00

808 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FOCAS Driver — Implementation Plan
> Source of gap analysis: [featuregaps.md → FOCAS](../featuregaps.md#focas-fanuc-cnc)
>
> Covers Build = Yes items only.
## Summary
The FOCAS driver today is a pure-managed, read-only FOCAS/2 wire client
(`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/`) backing a fixed-tree projection
plus user-authored `PARAM:` / `MACRO:` / PMC tags. It exposes a thin set of
calls (`cnc_sysinfo`, `cnc_rdcncstat`, `cnc_rdaxisname`, `cnc_rdspdlname`,
`cnc_rddynamic2`, `cnc_rdsvmeter`, `cnc_rdspload`, `cnc_rdspmaxrpm`,
`cnc_exeprgname2`, `cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdtimer`,
`cnc_rdparam`, `cnc_rdmacro`, `pmc_rdpmcrng`, `cnc_rdalmmsg2`).
The featuregaps table marks **18** items as Build = Yes. They cluster into
five distinct workstreams:
1. **Phase 1 — fixed-tree expansion** (#6, #7, #8, #10, #11, #12, #13, #14,
#18, #20, #24, #27). These are mostly new wire calls plumbed into the
existing `FixedTree*` poll cadences; no architectural change.
2. **Phase 2 — addressing additions** (#4, #14 DIAG scheme, #15, #16). New
`FocasAreaKind` values, new capability-matrix entries, multi-path
`PathId`. Touches the parser + matrix + wire envelope; mostly additive.
3. **Phase 3 — alarm history** (#17). Extends the existing
`FocasAlarmProjection` with a one-shot history pull on connect plus
periodic delta polls.
4. **Phase 4 — write path** (#1, #3). The biggest behavioural change in
the driver's lifetime: removes the `BadNotWritable` short-circuit, adds
`cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` plus FOCAS password
handling. Material risk surface — see Risks.
5. **Phase 5 — derived telemetry** (#24 cycle-delta computation). Optional
companion to #24 raw cycle time; computes "last completed cycle" from
the existing cumulative `Cycle` timer.
DIAG (#14) is in Phase 2 (addressing) rather than Phase 1 because it
needs a new address scheme, but the fixed-tree status flag projection
(#12) is the cheapest item and should land first as a vertical slice.
The remaining 9 items in the featuregaps table (HSSB, Series 15 / 35i,
tool-offset write, program upload/download, DPRNT, deep servo info,
acceleration/jerk, operator preset commands, NTP) are scoped out as
Build = No; they appear in [Skip-rated items](#skip-rated-items-for-context)
for context only.
## Phased delivery
| Phase | Scope | Gaps closed | Approx PRs | Risk |
|-------|-------|-------------|------------|------|
| 1 | Fixed-tree expansion (read-only) | 12, 13, 7, 8, 10, 11, 20, 18, 6, 24, 27, 14 (read-only piece) | 6 | Low |
| 2 | Addressing additions | 4, 15, 16, 14 (DIAG: scheme) | 4 | Medium (multi-path) |
| 3 | Alarm history | 17 | 1 | Low |
| 4 | Write path + password | 1, 3 | 4 | High (read-only design choice removed) |
| 5 | Cycle-delta derived telemetry | 24 (delta companion) | 1 | Low |
Phases 13 are mutually independent and can ship in any order. Phase 4
deliberately follows Phase 2 so writes ride on top of the multi-path
addressing already in place. Phase 5 tags onto the cycle-time node from
Phase 1.
## Per-PR detail
### Phase 1 — fixed-tree expansion
Common shape: each PR adds one or more wire calls in
`Wire/FocasWireClient.cs`, surfaces them on `IFocasClient`, plumbs them
into `FocasDriver`'s `FixedTreeLoopAsync` cadences (axis 250 ms / program
1 s / timer 30 s) and the `TryReadFixedTree` synthesizer, then adds
fakes + assertions.
**PR F1-a — ODBST status flags as fixed-tree nodes (#12)**
- Scope: project the 9 fields of `cnc_rdcncstat` (`tmmode`, `aut`, `run`,
`motion`, `mstb`, `emergency`, `alarm`, `edit`, `dummy`) under
`Status/` per device. We already issue this call in `ProbeAsync`; this
PR keeps the boolean probe but additionally caches the full struct on
every poll tick.
- Files:
`Wire/FocasWireClient.cs` (extend `ReadStatusAsync` to return the
whole `WireStatus` rather than only `IsOk`), `IFocasClient.cs` (new
`GetStatusAsync`), `FocasDriver.cs` (new `Status/*` branch in
`TryReadFixedTree`, status cache on `DeviceState`).
- Tests:
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeStatusTests.cs`
(new) — `FakeFocasClient` returns canned ODBST, assert each field maps
to the expected `Status/*` browse name. Integration: extend
`FocasSimFixture` to seed the simulator's status response and assert
via the OPC UA client.
- **Docs / fixture / e2e**: extend `docs/drivers/FOCAS.md` fixed-tree
table with the 9 `Status/*` nodes; mention the boolean-probe →
full-struct change in `docs/drivers/FOCAS-Test-Fixture.md` integration
bullet list; teach `focas-mock` (under
`tests/.../IntegrationTests/Docker/focas-mock/`) the `cnc_rdcncstat`
payload shape per `docs/v2/implementation/focas-wire-protocol.md`
(add ODBST struct entry); extend `FocasSimFixture` with a helper to
patch the canned status payload; new
`Series/StatusFlagsPopulateTests.cs` integration test.
- Effort: small; one wire call already exists.
- Risk: Low.
**PR F1-b — parts count + cycle time (#13, #24 raw)**
- Scope: surface `cnc_rdparam(6711)` (parts produced), `6712` (parts
required), `6713` (parts total since power-on) under `Production/`,
plus `Production/CycleTimeSeconds` (already exposed as
`Timers/CycleSeconds` — promote to the `Production/` group too with
the same backing). The existing `cnc_rdtimer` call is sufficient.
- Files: `FocasDriver.cs` (`Production/*` branch, parameter-cached
reads on the timer poll cadence), `IFocasClient.cs` (no new call —
rides on `ReadParameterInt32Async`).
- Tests: `FakeFocasClient` returns canned parameter values; assert
`Production/PartsTotal` equals the canned value.
- **Docs / fixture / e2e**: add `Production/*` rows to the fixed-tree
table in `docs/drivers/FOCAS.md`; add `Production:` example to
`docs/Driver.FOCAS.Cli.md` (a `read -a PARAM:6711` snippet); the
parts-count parameters (6711/6712/6713) are already in the
simulator profile range, so only the `dl205`-style profile JSON
under `tests/.../Docker/focas-mock/profiles/` needs seeded values
added; extend `FocasSimFixture` with a `SeedPartsCount` helper;
integration test under `Series/ProductionPopulatesTests.cs`.
- Effort: small.
- Risk: Low.
**PR F1-c — modal G/M/T codes (#7) + override values (#11)**
- Scope: add `cnc_modal` (command id TBD per `fwlib32.h` — the wire
protocol uses the same numeric command convention seen in
`FocasWireClient`; capture during simulator iteration). Project:
`Modal/G_Group{n}` (groups 1..21), `Modal/MCode`, `Modal/SCode`,
`Modal/TCode`, `Modal/BCode`. Adds `Override/Feed`, `Override/Rapid`,
`Override/Spindle`, `Override/Jog` from `cnc_rdparam(...)` — the
override percent registers live at known parameter numbers; numbers
are MTB-specific so pull defaults from
`docs/v2/focas-version-matrix.md` and let operators override per device.
- Files: `Wire/FocasWireClient.cs` (new `ReadModalAsync`), new
`Wire/FocasWireModels.cs` records `WireModal` / `WireModalGroup`,
`IFocasClient.cs` (new `GetModalAsync`), `FocasDriver.cs` (new
poll-medium branches under the program-poll cadence).
- Tests: `FocasModalTests.cs` (unit), simulator handler returns canned
modal payload, integration asserts `Modal/G_Group1` text.
- **Docs / fixture / e2e**: add `Modal/*` and `Override/*` sections to
the fixed-tree table in `docs/drivers/FOCAS.md`, including the
G-group decode table for groups 01/03/06/07/14; add a `MODAL:`
address example row to `docs/Driver.FOCAS.Cli.md` (new `read -a
MODAL:G1` style — note: this PR does NOT add a new address scheme,
the modal data is fixed-tree only, so the CLI example reads via
`read -n "ns=2;s=Modal/G_Group1"` over the OPC UA endpoint);
document MTB-specific override register defaults in
`docs/v2/focas-version-matrix.md` (new `Override registers per
series` table); capture the `cnc_modal` command id resolved during
simulator iteration into `docs/v2/implementation/focas-wire-protocol.md`
(new struct entry — promote out of the open-questions list);
update `docs/v2/implementation/focas-simulator-plan.md` Stream C
protocol-surface table with the new `cnc_modal` handler;
extend focas-mock with a `cnc_modal` command-id handler + canned
modal payload per profile; integration test reading G54/G90 modal
state via `Series/ModalPopulatesTests.cs`.
- Effort: medium — `cnc_modal` returns a multi-group struct; encoding
needs care.
- Risk: Medium — modal-group numbering varies by series; treat the
raw integer as the value the CNC reports and surface a string
decode table only for the universally-present groups (G-group 01
motion, 03 absolute/incremental, 06 input units, 07 cutter comp,
14 work coordinate). Document MTB-specific groups as raw int.
**PR F1-d — tool number / tool life (#8) + work coordinate offsets (#10)**
- Scope: add `cnc_rdtofs` / `cnc_rdtlife*` / `cnc_rdzofs`. Project
`Tooling/CurrentTool`, `Tooling/CurrentOffset`,
`Tooling/Life/{group}/Remaining`, `Tooling/Life/{group}/Total`,
`Offsets/G54..G59[+ extended]/{X,Y,Z}`.
- Files: new wire calls in `Wire/FocasWireClient.cs` (`ReadToolOffsetAsync`,
`ReadToolLifeAsync`, `ReadWorkOffsetAsync`), `Wire/FocasWireModels.cs`
(records), `IFocasClient.cs`, `FocasDriver.cs` (new `Tooling/` and
`Offsets/` branches; both poll on the slow timer cadence — these
change at setup time, not per-cycle), capability matrix per-call
suppression like the existing `Spindle/` gating.
- Tests: unit + simulator. Tool-life is the largest payload; assert
array projection rather than per-tool nodes (one ValueRank=1 array
per group keeps the address-space size bounded on machines with
500+ tool slots).
- **Docs / fixture / e2e**: add `Tooling/*` and `Offsets/*` sections to
the fixed-tree table in `docs/drivers/FOCAS.md`, including the
ValueRank=1 array note for tool-life groups; add a per-series
capability-suppression row to `docs/v2/focas-version-matrix.md`
(which series support `cnc_rdtlife*` vs not); document the three
new structs (`ODBTOFS`, `ODBTLIFE5`, `IODBZOR`) in
`docs/v2/implementation/focas-wire-protocol.md`; add
`cnc_rdtofs` / `cnc_rdtlife*` / `cnc_rdzofs` rows to the protocol
surface table in `docs/v2/implementation/focas-simulator-plan.md`;
extend focas-mock with three new command-id handlers + per-profile
seed data (tool table + work-offset table); add a
`tools_per_series` matrix to the `focas-mock` per-series profile
JSON so 0i-D's small tool table differs from 30i's; new
`Series/ToolingPopulatesTests.cs` and `Series/OffsetsPopulatesTests.cs`
integration tests; update `docs/drivers/FOCAS-Test-Fixture.md`
coverage map with the three new wire calls.
- Effort: large — three new calls, each with its own struct; tool-life
is variable-length.
- Risk: Medium — payload shapes are series-specific; keep the
capability matrix as the authoritative gate.
**PR F1-e — operator messages (#18) + currently-executing block text (#20)**
- Scope: `cnc_rdopmsg3` (gives all four FANUC opmsg classes in one
call), `cnc_rdactpt` (current block text). Project `Messages/External`
(variable, last-N strings), `Program/CurrentBlock` (single string).
- Files: `Wire/FocasWireClient.cs` (`ReadOperatorMessagesAsync`,
`ReadCurrentBlockAsync`), `IFocasClient.cs`, `FocasDriver.cs` (new
branches under program-poll cadence).
- Tests: simulator returns canned ASCII; assert string round-trip is
trim-stable (FANUC right-pads with `\0` or space).
- **Docs / fixture / e2e**: add `Messages/External` and
`Program/CurrentBlock` rows to the fixed-tree table in
`docs/drivers/FOCAS.md`, including the ring-buffer / last-N
semantics for opmsg; document the `OPMSG3` and `ODBACT2`
payload shapes in `docs/v2/implementation/focas-wire-protocol.md`;
add `cnc_rdopmsg3` / `cnc_rdactpt` rows to the protocol surface
table in `docs/v2/implementation/focas-simulator-plan.md`; extend
focas-mock with the two new command-id handlers (per-profile
canned message text + canned current-block text); add a
`mock_patch_opmsg` admin endpoint hook on `FocasSimFixture` for
tests that need to push a canned message; integration test
`Series/OperatorMessagesPopulateTests.cs` asserts trim-stable
round-trip and last-N retention.
- Effort: medium.
- Risk: Low — ASCII-only payloads.
**PR F1-f — `cnc_getfigure` decimal scaling (#6) + connection statistics (#27)**
- Scope: `cnc_getfigure` returns per-axis decimal-place counts; cache
the result at bootstrap and divide each `AbsolutePosition` /
`MachinePosition` / `RelativePosition` / `DistanceToGo` /
`ActualFeedRate` value before publishing. Existing nodes already
carry `Float64`; the change is invisible to clients except that
values become real-world units. Adds `Diagnostics/` subtree:
`Diagnostics/ReadCount`, `Diagnostics/ReadFailureCount`,
`Diagnostics/LastErrorMessage`, `Diagnostics/LastSuccessfulRead`,
`Diagnostics/ReconnectCount` — driven by counters already maintained
on `DeviceState`.
- Files: `Wire/FocasWireClient.cs` (new `ReadFigureAsync`),
`IFocasClient.cs`, `FocasDriver.cs` (cache decimal places per axis,
multiply on the read path, expose counters under `Diagnostics/`).
- Tests: assert that with a canned `cnc_getfigure` returning 3, an
`AbsolutePosition` of 12345 becomes `12.345`. Connection-stat tests
assert counters increment under known conditions.
- **Docs / fixture / e2e**: significant `docs/drivers/FOCAS.md` change —
add a "Decimal-place scaling" subsection explaining the
`FixedTree.ApplyFigureScaling` flag (default true on new installs,
false on migrations) and the unit-correctness semantics it enforces;
add `Diagnostics/*` rows to the fixed-tree table; add a
Diagnostics-counters subsection to `docs/v2/focas-deployment.md`
for operator dashboards; document `cnc_getfigure` (`ODBAXDP` /
`ODBAXIS`) struct in `docs/v2/implementation/focas-wire-protocol.md`;
add `cnc_getfigure` to the protocol surface in
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
with the per-axis decimal-place command handler + a `decimal_places`
field on each profile JSON; update
`docs/drivers/FOCAS-Test-Fixture.md` "When to trust each layer"
table with a "Are axis values reported in real-world units?" row;
add an opt-in `-CheckDecimalScaling` switch to `scripts/e2e/test-focas.ps1`
that asserts AbsolutePosition is scaled when the flag is on;
integration test `Series/DecimalScalingTests.cs` and
`Series/DiagnosticsCountersTests.cs`.
- Effort: medium — touches every axis read.
- Risk: Medium — this is a behavioural change for any existing
consumer that was already dividing client-side. Surface as a
`FixedTree.ApplyFigureScaling` opt-in flag (default true on new
installs, false when migrating); document in `docs/drivers/FOCAS.md`.
### Phase 2 — addressing additions
**PR F2-a — DIAG: address scheme (#14)**
- Scope: new `FocasAreaKind.Diagnostic` parsed from `DIAG:nnn` /
`DIAG:nnn/axis`, dispatched to `cnc_rddiag` (or `cnc_rddiagdgn` for
series that support it).
- Files: `FocasAddress.cs` (new prefix branch), `FocasCapabilityMatrix.cs`
(new `DiagnosticRange` per series), `Wire/FocasWireClient.cs`
(`ReadDiagnosticAsync`), `WireFocasClient.ReadAsync` (new dispatch
branch).
- Tests: parser unit tests, capability matrix unit tests, simulator
read-round-trip.
- **Docs / fixture / e2e**: add a `DIAG:` row to the address-syntax
table in `docs/Driver.FOCAS.Cli.md` with `read -a DIAG:301` and
`DIAG:301/0` (axis-scoped) examples; add a `DIAG:` row to the
addressing table in `docs/drivers/FOCAS.md`; add per-series
`DiagnosticRange` columns to `docs/v2/focas-version-matrix.md`;
document the `ODBDGN` struct in
`docs/v2/implementation/focas-wire-protocol.md`; add `cnc_rddiag`
/ `cnc_rddiagdgn` to the protocol surface in
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
with the diagnostic-range command handler + per-profile seeded
diagnostic numbers; integration test
`Series/DiagAddressTests.cs` round-trips a seeded diagnostic
number; update `docs/drivers/FOCAS-Test-Fixture.md` capability list
with the new `Diagnostic` `FocasAreaKind`.
- Effort: medium.
- Risk: Low — additive.
**PR F2-b — Multi-path / multi-channel CNC (#4)**
- Scope: 30i/31i/32i can host 210 paths; today every request block is
built with `PathId = 1` (`Wire/FocasWireProtocol.cs:216`). Add
optional `Path` segment to `FocasAddress` (e.g. `PARAM:1815@2`,
`R100@3.0`, `MACRO:500@2`); thread it into the `RequestBlock.PathId`
field. Fixed-tree gets a `Paths/{n}/` folder pivot.
- Files: `FocasAddress.cs` (new `Path` field + parser), `IFocasClient.cs`
(every read call gains an optional `pathId` parameter, defaulting to
1 for backward compatibility), `Wire/FocasWireClient.cs`
(thread the param through every `RequestBlock` constructor),
`FocasDriver.cs` (per-device `PathCount` discovery via
`cnc_rdpathnum`; iterate fixed-tree per path).
- Tests: unit on the parser; simulator with two paths configured;
assert that a `PARAM:1815@2` read targets path 2.
- **Docs / fixture / e2e**: significant `docs/drivers/FOCAS.md`
update — new "Multi-path / multi-channel CNC" subsection explaining
the `@N` suffix syntax, `Paths/{n}/` browse pivot, and per-path
capability gating; add `@N` to every address row in the
addressing table in `docs/Driver.FOCAS.Cli.md`; document
`cnc_rdpathnum` (`ODBPATHNUM` struct) in
`docs/v2/implementation/focas-wire-protocol.md`, and update the
`RequestBlock.PathId` discussion (was hard-coded to 1 — now a
parameter); add `cnc_rdpathnum` to the protocol surface and the
per-profile `path_count` field to the profile schema in
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
with per-path state isolation (separate PMC / param / macro tables
per `path_id`) and a new `multi_path` profile (e.g.
`thirtyone_i_dual_path`); add a `-Paths` switch to
`scripts/e2e/test-focas.ps1` that runs the matrix once per
declared path; document the new compose profile in
`docs/drivers/FOCAS-Test-Fixture.md`; new
`Series/MultiPathTests.cs` integration test asserting independent
per-path reads.
- Effort: large — touches every wire call's `RequestBlock` shape.
- Risk: Medium — backward compatibility for existing single-path
configs. Default `PathId = 1` everywhere; only deviate when the
address explicitly carries a `@N` suffix or when the fixed-tree
loop is iterating discovered paths.
**PR F2-c — PMC F/G letters for 16i (#15)**
- Scope: capability matrix bug — `PmcLetters(Sixteen_i)` currently
returns `{X, Y, R, D}`; real 16i ladders use F/G for handshakes.
Widen the set; verify the address `pmc_rdpmcrng` numeric letter
codes match.
- Files: `FocasCapabilityMatrix.cs` (one-line fix to the 16i case),
`tests/.../FocasCapabilityMatrixTests.cs` (assert F0.0 and G50.5
parse against `Sixteen_i`).
- **Docs / fixture / e2e**: update the 16i row of the PMC-letters
column in `docs/v2/focas-version-matrix.md` (the row currently lists
X/Y/R/D — add F/G); add a one-line "fixed in v…" callout to the
changelog section of the same doc; no simulator change required (the
16i profile JSON in `tests/.../Docker/focas-mock/profiles/sixteen_i.json`
already has F/G ranges declared from Stream B); add F0.0 / G50.5
probes to the 16i row of the per-series matrix in
`scripts/e2e/test-focas.ps1`; no fixture-doc change needed.
- Effort: trivial.
- Risk: Low — correctness fix.
**PR F2-d — Bulk PMC range read (#16)**
- Scope: today the driver issues one `pmc_rdpmcrng` per tag (one TCP
RTT each). The wire call already supports a range `[start, end]`;
the missing piece is coalescing on the read side. Add a coalescer:
group same-letter contiguous (or near-contiguous within a small
gap budget) PMC bytes from the request batch into one wire call
per group, then slice client-side. Reuse the Modbus coalescing
infrastructure pattern (per-group-id ProhibitedRanges) where it
applies.
- Files: new `Wire/FocasPmcCoalescer.cs`, hook into
`FocasDriver.ReadAsync` between the per-tag path and the wire call
layer. Surface coalesce stats on the `Diagnostics/` subtree (PR F1-f).
- Tests: unit — given a request batch of `R100..R110`, assert that
the coalescer issues one call covering 100..110 and slices the
result. Integration — assert observed wire-call count drops with
coalescing on.
- **Docs / fixture / e2e**: add a "PMC range coalescing" subsection
to `docs/drivers/FOCAS.md` (wire-call reduction, gap budget,
per-series byte cap); document the new `Diagnostics/CoalesceStats/*`
counters added on top of PR F1-f's diagnostics tree; add a
PMC-byte-cap column to `docs/v2/focas-version-matrix.md`;
no new wire calls (`pmc_rdpmcrng` is already in the surface), but
document the supported max-bytes-per-call in
`docs/v2/implementation/focas-wire-protocol.md`; extend focas-mock
with a request-counter admin endpoint so integration tests can
assert the call-count reduction (counter visible via
`FocasSimFixture.GetWireCallCountAsync`); update
`docs/v2/implementation/focas-simulator-plan.md` Stream B
validation harness with the request-counter handler; integration
test `Series/PmcCoalescingTests.cs` asserts an `R100..R110` batch
produces exactly 1 wire call against the mock.
- Effort: medium.
- Risk: Medium — the FANUC max-bytes-per-`pmc_rdpmcrng` ceiling is
series-specific; cap conservatively (≤ 256 bytes per range) and
let operators raise it via config if their CNC accepts more.
### Phase 3 — alarm history
**PR F3-a — `cnc_rdalmhistry` extension to alarm projection (#17)**
- Scope: extend `FocasAlarmProjection` with two modes — `ActiveOnly`
(today's behaviour) and `ActivePlusHistory`. In the latter, on
connect (and on a configurable cadence — default 5 min, since the
CNC ring buffer changes only on alarm raise/clear) issue
`cnc_rdalmhistry` for the most-recent N entries; project as
historic events through `IAlarmSource` with `OccurrenceTime` from
the CNC's timestamp field.
- Files: new `Wire/FocasWireClient.ReadAlarmHistoryAsync`, new
`IFocasClient.ReadAlarmHistoryAsync`,
`FocasAlarmProjection.cs` (mode switch + history poll loop),
`FocasDriverOptions.cs` (`AlarmProjection.Mode` enum +
`HistoryPollInterval` + `HistoryDepth`).
- Tests: simulator returns canned history payload; assert events
fire with the timestamps from the canned data and don't re-fire
on every poll.
- **Docs / fixture / e2e**: add an "Alarm history" subsection to
`docs/drivers/FOCAS.md` documenting the `ActiveOnly` vs
`ActivePlusHistory` mode switch, the `HistoryDepth` cap, and the
dedup key; add a configuration-knob row to
`docs/v2/focas-deployment.md` for operator dashboards; document
`ODBALMHIS` struct in
`docs/v2/implementation/focas-wire-protocol.md`; add
`cnc_rdalmhistry` to the protocol surface in
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
with a ring-buffer alarm history (per profile) + `mock_patch_alarmhistory`
admin endpoint; expose a `SeedAlarmHistoryAsync` helper on
`FocasSimFixture`; add `Series/AlarmHistoryProjectionTests.cs`
asserting historic events fire once and active events still fire
raise/clear; update `docs/drivers/FOCAS-Test-Fixture.md` integration
bullet list with `cnc_rdalmhistry`.
- Effort: medium.
- Risk: Medium — duplicate-event suppression; key history events on
`(timestamp, alarmNumber, type)` to deduplicate against the active
list.
### Phase 4 — write path
This phase is the major behavioural change. The driver's read-only
contract has been the documented design choice in
`docs/drivers/FOCAS.md:14-18` and is reinforced by tests
(`FocasReadWriteTests.WriteAsync_ReturnsBadNotWritable`). Removing it
deserves a deliberate decision-record entry in the v2 decisions log
before any code lands.
**PR F4-a — write infrastructure + per-tag opt-in (no wire calls yet)**
- Scope: drop the `BadNotWritable` short-circuit in
`WireFocasClient.WriteAsync` and replace with a kind-based dispatch
that returns `BadNotWritable` only for kinds the wire client
doesn't yet implement. Honour `FocasTagDefinition.Writable` (already
present, default `true` — flip default to `false` per #1's safer
posture). Plumb `WriteIdempotent` through Polly retry.
- Files: `WireFocasClient.cs`, `FocasDriverOptions.cs`,
`FocasDriver.cs`, `docs/drivers/FOCAS.md` (rewrite the read-only
paragraph), new `docs/v2/decisions.md` entry.
- Tests: assert that with `Writable=false` the path still returns
`BadNotWritable`; with `Writable=true` and an unimplemented kind
the write returns `BadNotSupported` (distinct from the per-tag
policy denial).
- **Docs / fixture / e2e**: this is the heaviest doc PR in the plan.
- **`docs/drivers/FOCAS.md` lines 1418** — revoke the unconditional
"OtOpcUa is read-only against FOCAS… Writes return BadNotWritable
by design" callout. Replace with a "Writes (opt-in, off by
default)" subsection that names `Writes.Enabled`, the per-tag
`Writable` flag (default flipped to `false`), and links to the
Phase 4 decision-record entry.
- **`docs/drivers/FOCAS-Test-Fixture.md` lines 4243** — revoke the
"`IWritable` intentionally returns `BadNotWritable` — OtOpcUa is
read-only against FOCAS" callout. Replace with a qualified
"default behaviour" note plus a pointer to the new write-enabled
test profile.
- **`docs/Driver.FOCAS.Cli.md` lines 100116** — the existing
`write` section already documents the CLI shape; expand the
"**Writes are non-idempotent by default**" warning with a
server-side note that the OtOpcUa endpoint enforces the
`Writes.Enabled` flag and rejects writes when off, and that
the CLI itself talks to the driver directly so its writes are
not gated by the server flag (operator must consciously use
the right tool).
- New `docs/v2/decisions.md` entry "FOCAS write-path opt-in"
capturing the design-choice reversal.
- Update `docs/featuregaps.md` row for #1 / #3 — flip Build = Yes
annotation to "shipping behind flag".
- Simulator: no new commands; existing read commands gain a
"writes when not unlocked" branch wired up here for symmetry
even though no write commands ship yet (returns
`BadNotSupported` until F4-b lands).
- E2E: add `-Write` switch (no-op stage in this PR; populated by
F4-b) to `scripts/e2e/test-focas.ps1`.
- Effort: medium.
- Risk: High — design-choice reversal. Mitigation: ship behind a
driver-level `Writes.Enabled` flag (default `false`); operators
must explicitly enable in `appsettings.json`.
**PR F4-b — `cnc_wrmacro` + `cnc_wrparam`**
- Scope: implement macro and parameter writes. Both have well-defined
payload shapes mirroring their read counterparts (IODBPSD for
parameters, ODBM for macros).
- Files: `Wire/FocasWireClient.cs` (new `WriteParameterAsync`,
`WriteMacroAsync`), `WireFocasClient.WriteAsync` (dispatch).
- Tests: simulator extension — accept writes and reflect them on
subsequent reads. ACL tests in
`tests/ZB.MOM.WW.OtOpcUa.IntegrationTests` to verify the
server-layer enforcement (per the memory entry: ACL decisions
happen in `DriverNodeManager`, never in driver-level code).
- **Docs / fixture / e2e**:
- `docs/drivers/FOCAS.md` — extend the "Writes" subsection
(introduced in F4-a) with the two new write kinds, the
`Writes.AllowParameter` and `Writes.AllowMacro` granular flags,
and a security note: parameter writes require LDAP group
`WriteConfigure`, macro writes require `WriteOperate` (cross-link
to `docs/Security.md`).
- `docs/v2/focas-deployment.md` — significant addition: a "Write
safety" section covering operator pre-checks (CNC in MDI mode,
parameter-write switch enabled), audit-log expectations, and the
LDAP group requirements.
- `docs/Driver.FOCAS.Cli.md` — populate the existing `write`
examples for `PARAM:` and `MACRO:` (already present at lines
105108) with a "Server-enforced ACL" note linking to
`docs/Security.md`.
- Document `IODBPSD` (write side) and `ODBM` (write side) in
`docs/v2/implementation/focas-wire-protocol.md` (the read-side
structs are already there — flag the byte layout symmetry).
- `docs/v2/implementation/focas-simulator-plan.md` — add
`cnc_wrparam` / `cnc_wrmacro` to the protocol surface table
and update Stream C status accordingly.
- Extend focas-mock with `cnc_wrparam` / `cnc_wrmacro` handlers
that mutate the per-profile state and return
`EW_PASSWD` when the unlock state is off (sets up F4-d's
test path); add `mock_get_last_write` admin endpoint for
audit-log assertions.
- New `Series/ParameterWriteTests.cs` and `Series/MacroWriteTests.cs`
integration tests; ACL test under
`tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.cs`
asserting `WriteConfigure` is required for `PARAM:` writes and
`WriteOperate` for `MACRO:` writes.
- `scripts/e2e/test-focas.ps1` — populate the `-Write` stage from
F4-a with macro and parameter round-trip writes against the
Docker mock.
- Effort: medium.
- Risk: High — a misdirected parameter write can put the CNC into a
bad state. Surface a `Writes.AllowParameter` flag separate from
`Writes.Enabled` so operators can grant macro writes without
parameter writes.
**PR F4-c — `pmc_wrpmcrng`**
- Scope: PMC range writes. Read-modify-write semantics for bit-level
writes (the wire call is byte-addressed). Existing tests
(`FocasPmcBitRmwTests.cs`) prove the read-modify-write pattern
shape that the write path needs.
- Files: `Wire/FocasWireClient.cs` (new `WritePmcRangeAsync`),
bit-level RMW helper in `WireFocasClient`.
- Tests: simulator round-trip on byte writes; bit-level write asserts
the unrelated bits in the same byte are preserved.
- **Docs / fixture / e2e**:
- `docs/drivers/FOCAS.md` — extend the "Writes" subsection with
PMC writes; loud safety callout block ("PMC is ladder working
memory — a mistargeted bit can move motion"); document the
read-modify-write semantics for bit-level writes; document the
new `Writes.AllowPmc` granular flag.
- `docs/v2/focas-deployment.md` — extend the "Write safety"
section with PMC-specific pre-checks (e-stop, jog mode); add an
ops-runbook bullet on auditing PMC writes from the
`Diagnostics/CoalesceStats/` (extended) tree.
- `docs/Driver.FOCAS.Cli.md` — the existing `write` example
`write -h … -a G50.3 -t Bit -v on` (line 107) is already PMC-bit;
update its surrounding warning to call out RMW behaviour.
- Document the `pmc_wrpmcrng` request frame in
`docs/v2/implementation/focas-wire-protocol.md` (the read frame
is already there — flag the inverted shape).
- `docs/v2/implementation/focas-simulator-plan.md` — add
`pmc_wrpmcrng` to the protocol surface table.
- Extend focas-mock with `pmc_wrpmcrng` handler that mutates
per-profile PMC tables; assert byte-aligned writes preserve
untouched bytes (mirrors the driver's RMW contract).
- New `Series/PmcRangeWriteTests.cs` and
`Series/PmcBitRmwIntegrationTests.cs` integration tests; ACL
test under
`tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasPmcWriteAclTests.cs`
asserting `WriteOperate` is required.
- `scripts/e2e/test-focas.ps1` — extend the `-Write` stage with a
PMC bit round-trip.
- Effort: medium.
- Risk: High — PMC is the ladder logic's working memory; a
mistargeted write can move motion. Document loudly.
**PR F4-d — FOCAS password / unlock parameter (#3)**
- Scope: some controllers gate `cnc_wrparam` and certain reads behind
a connection-level password. Add `Password` to `FocasDeviceOptions`;
emit the FOCAS password block during connect (`cnc_wrunlockparam`
per FOCAS docs — confirm the exact command id during simulator
iteration). On any read/write returning `EW_PASSWD`
re-issue the password and retry once.
- Files: `Wire/FocasWireClient.cs` (`UnlockAsync`),
`FocasDriverOptions.cs` (`Password` field, treated as a secret —
redact in logs), `FocasDriver.cs` (call on connect).
- Tests: simulator extension — emit `EW_PASSWD` on writes when not
unlocked; assert the unlock+retry path.
- **Docs / fixture / e2e**:
- `docs/drivers/FOCAS.md` — new "FOCAS password" subsection under
Writes describing the optional `Password` device-option, when
the CNC requires it (16i + some 30i firmwares with parameter-
protect on), and the redaction guarantee.
- **Security-note in `docs/v2/focas-deployment.md`** — significant
addition: a "FOCAS password handling" subsection covering
storage in `appsettings.json` (and the dev redaction pattern at
`.local/`), the no-log invariant, and a runbook for password
rotation. Cross-link to `docs/Security.md`.
- `docs/Driver.FOCAS.Cli.md` — add a `--cnc-password` flag row to
the "Common flags" table with the redaction note.
- Document `cnc_wrunlockparam` (or the resolved command id) in
`docs/v2/implementation/focas-wire-protocol.md`; resolve the
open question raised by F4-d into the doc.
- `docs/v2/implementation/focas-simulator-plan.md` — add
`cnc_wrunlockparam` to the protocol surface; document the
per-profile `unlock_password` field on the JSON profile schema.
- Extend focas-mock with locked-state semantics on parameter
writes (already half-stubbed in F4-b's `EW_PASSWD` branch);
add `cnc_wrunlockparam` handler; add `mock_set_password`
admin endpoint so integration tests can pin the unlock value.
- New `Series/PasswordUnlockTests.cs` integration test asserts
a write returning `EW_PASSWD` triggers exactly one unlock
retry, and the second write succeeds.
- `scripts/e2e/test-focas.ps1` — add `-CncPassword` parameter,
threaded through to the CLI for the `-Write` stage.
- Effort: small — once Phase 4-a/b are in.
- Risk: Medium — password storage. Use the existing
`appsettings.json` redaction pattern (memory entry: `dohertj2`
AppData path); never log the password value.
### Phase 5 — derived telemetry
**PR F5-a — Cycle time per part / last cycle delta (#24 derivation)**
- Scope: with `Production/CycleTimeSeconds` in place from F1-b and
the parts-count from `cnc_rdparam`, compute "last completed cycle"
as the delta in `Timers/CycleSeconds` between successive
parts-count increments. Project `Production/LastCycleSeconds`,
`Production/LastCycleStartUtc`.
- Files: `FocasDriver.cs` only — pure derivation in the program-poll
cadence handler.
- Tests: simulate a parts-count increment from 5→6; assert
`LastCycleSeconds` equals the cycle-timer delta over the same
window.
- **Docs / fixture / e2e**: add `Production/LastCycleSeconds` and
`Production/LastCycleStartUtc` rows to the fixed-tree table in
`docs/drivers/FOCAS.md` with the rollover / counter-reset
behaviour documented; add a `Derived telemetry` callout in
`docs/v2/focas-deployment.md` explaining the derivation is
client-visible only (no new wire calls); no
`docs/v2/implementation/focas-wire-protocol.md` change (pure
derivation); no focas-mock change beyond `FocasSimFixture`'s
existing parameter-patch / timer-patch helpers — add a
`SimulateCycleCompletionAsync` convenience helper that increments
parts-count and advances the cycle timer atomically; new
`Series/CycleDeltaTests.cs` integration test simulates a 5→6
parts-count transition; no `scripts/e2e/test-focas.ps1` change.
- Effort: small.
- Risk: Low — pure derivation.
## Documentation, fixture, and e2e impact
Consolidated view of every doc, fixture, and e2e artefact this plan
touches. FOCAS has the largest doc surface of any driver in the v2
roadmap because Phase 4 reverses a long-standing read-only design
choice that is referenced from at least three user-facing docs and one
test-fixture doc.
### Docs touched (per file, with the heaviest PR called out)
| Doc | Touched by | Heaviest change |
| --- | --- | --- |
| `docs/drivers/FOCAS.md` | F1-a, F1-b, F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-a, F4-b, F4-c, F4-d, F5-a | **F4-a** revokes the read-only callout at lines 1418; **F2-b** adds the multi-path subsection |
| `docs/drivers/FOCAS-Test-Fixture.md` | F1-a, F1-d, F1-f, F2-a, F2-b, F3-a, F4-a | **F4-a** revokes the "`IWritable` intentionally returns `BadNotWritable`" callout at lines 4243 |
| `docs/Driver.FOCAS.Cli.md` | F1-b, F1-c, F2-a, F2-b, F4-a, F4-b, F4-c, F4-d | **F4-a** qualifies the read-only stance at lines 100116; **F4-d** adds `--cnc-password` flag |
| `docs/v2/focas-deployment.md` | F1-f, F3-a, F4-a, F4-b, F4-c, F4-d | **F4-b** adds "Write safety" section; **F4-d** adds "FOCAS password handling" section |
| `docs/v2/focas-version-matrix.md` | F1-c, F1-d, F2-a, F2-c, F2-d | **F1-d** adds capability-suppression rows for tooling/offsets |
| `docs/v2/implementation/focas-wire-protocol.md` | F1-a, F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-b, F4-c, F4-d | **F1-d** documents three new structs (ODBTOFS, ODBTLIFE5, IODBZOR); **F4-d** resolves the `cnc_wrunlockparam` open question |
| `docs/v2/implementation/focas-simulator-plan.md` | F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-a, F4-b, F4-c, F4-d | Each PR appends to the protocol surface table; F4-* close out Stream C status |
| `docs/v2/decisions.md` (new entry) | F4-a | Net-new decision-record for the read-only reversal |
| `docs/featuregaps.md` | F4-a | Updates Build = Yes annotation for #1 / #3 with "shipping behind flag" |
### Fixture (focas-mock) extensions
The vendored Python `focas-mock` simulator under
`tests/.../IntegrationTests/Docker/focas-mock/` gains the following
new command-id handlers and per-profile state:
| PR | Mock extension |
| --- | --- |
| F1-a | `cnc_rdcncstat` full-struct response |
| F1-b | Seeded values for parameters 6711/6712/6713 in every profile JSON |
| F1-c | New `cnc_modal` handler + canned modal payload per profile |
| F1-d | `cnc_rdtofs` / `cnc_rdtlife*` / `cnc_rdzofs` handlers + per-profile tool/offset tables, plus a `tools_per_series` profile knob |
| F1-e | `cnc_rdopmsg3` / `cnc_rdactpt` handlers + `mock_patch_opmsg` admin endpoint |
| F1-f | `cnc_getfigure` handler + per-profile `decimal_places` field |
| F2-a | `cnc_rddiag` / `cnc_rddiagdgn` handlers + per-profile diagnostic numbers |
| F2-b | Per-path state isolation; new `path_count` profile field; new `thirtyone_i_dual_path` compose profile |
| F2-c | No mock change (16i profile already declares F/G ranges) |
| F2-d | Wire-call counter admin endpoint |
| F3-a | Ring-buffer alarm history + `mock_patch_alarmhistory` admin endpoint |
| F4-a | Stub branch returning `BadNotSupported` for write commands |
| F4-b | `cnc_wrparam` / `cnc_wrmacro` handlers (with `EW_PASSWD` when locked); `mock_get_last_write` admin endpoint |
| F4-c | `pmc_wrpmcrng` handler with byte-aligned write semantics |
| F4-d | `cnc_wrunlockparam` handler; `mock_set_password` admin endpoint; locked-state on the param-write path |
| F5-a | `SimulateCycleCompletionAsync` helper on `FocasSimFixture` (no new mock command) |
`FocasSimFixture` (in
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs`)
gains corresponding admin-API client helpers for each new endpoint.
### Integration tests (per phase)
| Phase | New / extended integration tests under `tests/.../FOCAS.IntegrationTests/Series/` |
| --- | --- |
| Phase 1 | `StatusFlagsPopulateTests.cs`, `ProductionPopulatesTests.cs`, `ModalPopulatesTests.cs`, `ToolingPopulatesTests.cs`, `OffsetsPopulatesTests.cs`, `OperatorMessagesPopulateTests.cs`, `DecimalScalingTests.cs`, `DiagnosticsCountersTests.cs` |
| Phase 2 | `DiagAddressTests.cs`, `MultiPathTests.cs`, `PmcCoalescingTests.cs` (plus a 16i row in `FocasCapabilityMatrixTests.cs` for F2-c) |
| Phase 3 | `AlarmHistoryProjectionTests.cs` |
| Phase 4 | `ParameterWriteTests.cs`, `MacroWriteTests.cs`, `PmcRangeWriteTests.cs`, `PmcBitRmwIntegrationTests.cs`, `PasswordUnlockTests.cs` plus ACL tests under `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.cs` and `FocasPmcWriteAclTests.cs` |
| Phase 5 | `CycleDeltaTests.cs` |
### E2E script (`scripts/e2e/test-focas.ps1`) updates
| PR | Change |
| --- | --- |
| F1-f | New `-CheckDecimalScaling` switch |
| F2-b | New `-Paths` switch (matrix mode iterates per declared path) |
| F2-c | Adds F0.0 / G50.5 probes to the 16i row of the per-series matrix |
| F4-a | Adds `-Write` switch (no-op stage in F4-a; populated by F4-b/c) |
| F4-b | Populates `-Write` stage with macro + parameter round-trip writes |
| F4-c | Extends `-Write` stage with PMC bit round-trip |
| F4-d | Adds `-CncPassword` parameter, threaded through to the CLI |
`scripts/integration/run-focas.ps1` does not change shape across the
plan — it remains the compose up/test/compose down wrapper. New
profiles registered by F2-b are automatically picked up via the
existing `-Profile` switch.
### Read-only callouts requiring revocation in Phase 4
For reviewer benefit, the explicit read-only callouts that **F4-a
must revoke or qualify** in the same PR that flips the design choice:
- `docs/drivers/FOCAS.md` lines 1418 ("OtOpcUa is **read-only**
against FOCAS… Writes return `BadNotWritable` by design.")
- `docs/drivers/FOCAS-Test-Fixture.md` lines 4243 ("`IWritable`
intentionally returns `BadNotWritable` — OtOpcUa is read-only
against FOCAS.")
- `docs/Driver.FOCAS.Cli.md` lines 100116 (write section is already
documented but predates the server-side flag; needs a
server-enforced-ACL note)
- `docs/featuregaps.md` (FOCAS row entries for #1 and #3 carry the
same read-only-by-design framing — flip annotation)
## Skip-rated items (for context)
These appear in the featuregaps recommendations table as Build = No;
recapped here so reviewers can confirm the scope decision rather than
re-deriving it from `featuregaps.md`:
- **#2 HSSB transport** — PCI hardware, declining install base,
reopens the Fwlib distribution problem the wire client deliberately
closed.
- **#5 Series 15 / Power Mate D-H / Series 35i** — very legacy; small
install base. Capability matrix already accepts `Unknown` as a
permissive escape hatch.
- **#9 Tool-offset write** — write-heavy; defer alongside the general
write decision (F4 covers reads via tool-life only).
- **#19 Program list / upload / download / delete** — DNC product
territory; significant scope; out of OtOpcUa's MES focus.
- **#21 DPRNT TCP listener** — significant scope; modern OPC UA
alarms / events supersede it.
- **#22 Servo / spindle deep info (`cnc_rdsvinfo` / `cnc_rdspinfo`)** —
specialty; load-percent already covers most needs.
- **#23 Per-axis acceleration / jerk / feed-per-rev** — niche
advanced telemetry.
- **#25 Operator write commands (preset, `cnc_setpath`, `cnc_wrabsmac`)** —
read-only-by-design covers it; parameter / PMC / macro writes from
Phase 4 are the supervisory writes operators actually need.
- **#26 CNC time / date sync** — rare ask; commonly handled by CNC NTP.
## Open questions
- **Modal command id** (PR F1-c): `cnc_modal` numeric command code is
not in the existing wire-protocol notes
(`docs/v2/implementation/focas-wire-protocol.md`). Capture during
the simulator iteration loop; if the simulator can't yet emit the
shape, gate F1-c behind a bench-CNC trace per the
diminishing-returns checkpoint.
- **Override parameter numbers** (PR F1-c): feedrate / rapid /
spindle override register numbers are MTB-specific. Default to the
documented Fanuc factory numbers and let operators override per
device (`Devices[].OverrideRegisters` map).
- **Multi-path discovery** (PR F2-b): does the simulator support
multi-path responses today? If not, F2-b lands gated behind the
`OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1` flag the wire-protocol doc
describes.
- **Decimal-scaling migration** (PR F1-f): existing `Float64` axis
nodes are scaled integers today. Decision: ship F1-f with
scaling-on default, add a one-release deprecation window with the
flag default-off so existing dashboards don't silently scale by
10^N when the driver is upgraded. Need explicit operator opt-in.
- **Write security posture** (Phase 4): should writes require LDAP
group `WriteConfigure` (parameters) vs `WriteOperate` (macros /
PMC)? Per the memory entry on ACL-at-server-layer, the driver only
reports `SecurityClassification`; the server enforces. Need the
driver to surface the right classification per address kind:
`Configure` for `PARAM:`, `Operate` for `MACRO:` and PMC writes.
- **Phase 4 rollout**: ship behind a feature flag in `appsettings.json`
(`Drivers.{name}.Config.Writes.Enabled`) with `false` default for at
least one release before flipping the default. Update
`docs/drivers/FOCAS.md` and `docs/featuregaps.md` in the same PR
that flips the default.
- **Cycle-delta edge cases** (PR F5-a): parts-count rollover; counter
reset by the operator. Default behaviour: emit the delta only when
the counter strictly increments by 1; on any other transition emit
`Production/LastCycleSeconds` as `null` with `BadOutOfRange` and
let the operator interpret.