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>
44 KiB
FOCAS Driver — Implementation Plan
Source of gap analysis: featuregaps.md → FOCAS
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:
- 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. - Phase 2 — addressing additions (#4, #14 DIAG scheme, #15, #16). New
FocasAreaKindvalues, new capability-matrix entries, multi-pathPathId. Touches the parser + matrix + wire envelope; mostly additive. - Phase 3 — alarm history (#17). Extends the existing
FocasAlarmProjectionwith a one-shot history pull on connect plus periodic delta polls. - Phase 4 — write path (#1, #3). The biggest behavioural change in
the driver's lifetime: removes the
BadNotWritableshort-circuit, addscnc_wrparam/pmc_wrpmcrng/cnc_wrmacroplus FOCAS password handling. Material risk surface — see Risks. - Phase 5 — derived telemetry (#24 cycle-delta computation). Optional
companion to #24 raw cycle time; computes "last completed cycle" from
the existing cumulative
Cycletimer.
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 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 1–3 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) underStatus/per device. We already issue this call inProbeAsync; this PR keeps the boolean probe but additionally caches the full struct on every poll tick. - Files:
Wire/FocasWireClient.cs(extendReadStatusAsyncto return the wholeWireStatusrather than onlyIsOk),IFocasClient.cs(newGetStatusAsync),FocasDriver.cs(newStatus/*branch inTryReadFixedTree, status cache onDeviceState). - Tests:
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeStatusTests.cs(new) —FakeFocasClientreturns canned ODBST, assert each field maps to the expectedStatus/*browse name. Integration: extendFocasSimFixtureto seed the simulator's status response and assert via the OPC UA client. - Docs / fixture / e2e: extend
docs/drivers/FOCAS.mdfixed-tree table with the 9Status/*nodes; mention the boolean-probe → full-struct change indocs/drivers/FOCAS-Test-Fixture.mdintegration bullet list; teachfocas-mock(undertests/.../IntegrationTests/Docker/focas-mock/) thecnc_rdcncstatpayload shape perdocs/v2/implementation/focas-wire-protocol.md(add ODBST struct entry); extendFocasSimFixturewith a helper to patch the canned status payload; newSeries/StatusFlagsPopulateTests.csintegration 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) underProduction/, plusProduction/CycleTimeSeconds(already exposed asTimers/CycleSeconds— promote to theProduction/group too with the same backing). The existingcnc_rdtimercall is sufficient. - Files:
FocasDriver.cs(Production/*branch, parameter-cached reads on the timer poll cadence),IFocasClient.cs(no new call — rides onReadParameterInt32Async). - Tests:
FakeFocasClientreturns canned parameter values; assertProduction/PartsTotalequals the canned value. - Docs / fixture / e2e: add
Production/*rows to the fixed-tree table indocs/drivers/FOCAS.md; addProduction:example todocs/Driver.FOCAS.Cli.md(aread -a PARAM:6711snippet); the parts-count parameters (6711/6712/6713) are already in the simulator profile range, so only thedl205-style profile JSON undertests/.../Docker/focas-mock/profiles/needs seeded values added; extendFocasSimFixturewith aSeedPartsCounthelper; integration test underSeries/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 perfwlib32.h— the wire protocol uses the same numeric command convention seen inFocasWireClient; capture during simulator iteration). Project:Modal/G_Group{n}(groups 1..21),Modal/MCode,Modal/SCode,Modal/TCode,Modal/BCode. AddsOverride/Feed,Override/Rapid,Override/Spindle,Override/Jogfromcnc_rdparam(...)— the override percent registers live at known parameter numbers; numbers are MTB-specific so pull defaults fromdocs/v2/focas-version-matrix.mdand let operators override per device. - Files:
Wire/FocasWireClient.cs(newReadModalAsync), newWire/FocasWireModels.csrecordsWireModal/WireModalGroup,IFocasClient.cs(newGetModalAsync),FocasDriver.cs(new poll-medium branches under the program-poll cadence). - Tests:
FocasModalTests.cs(unit), simulator handler returns canned modal payload, integration assertsModal/G_Group1text. - Docs / fixture / e2e: add
Modal/*andOverride/*sections to the fixed-tree table indocs/drivers/FOCAS.md, including the G-group decode table for groups 01/03/06/07/14; add aMODAL:address example row todocs/Driver.FOCAS.Cli.md(newread -a MODAL:G1style — note: this PR does NOT add a new address scheme, the modal data is fixed-tree only, so the CLI example reads viaread -n "ns=2;s=Modal/G_Group1"over the OPC UA endpoint); document MTB-specific override register defaults indocs/v2/focas-version-matrix.md(newOverride registers per seriestable); capture thecnc_modalcommand id resolved during simulator iteration intodocs/v2/implementation/focas-wire-protocol.md(new struct entry — promote out of the open-questions list); updatedocs/v2/implementation/focas-simulator-plan.mdStream C protocol-surface table with the newcnc_modalhandler; extend focas-mock with acnc_modalcommand-id handler + canned modal payload per profile; integration test reading G54/G90 modal state viaSeries/ModalPopulatesTests.cs. - Effort: medium —
cnc_modalreturns 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. ProjectTooling/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(newTooling/andOffsets/branches; both poll on the slow timer cadence — these change at setup time, not per-cycle), capability matrix per-call suppression like the existingSpindle/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/*andOffsets/*sections to the fixed-tree table indocs/drivers/FOCAS.md, including the ValueRank=1 array note for tool-life groups; add a per-series capability-suppression row todocs/v2/focas-version-matrix.md(which series supportcnc_rdtlife*vs not); document the three new structs (ODBTOFS,ODBTLIFE5,IODBZOR) indocs/v2/implementation/focas-wire-protocol.md; addcnc_rdtofs/cnc_rdtlife*/cnc_rdzofsrows to the protocol surface table indocs/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 atools_per_seriesmatrix to thefocas-mockper-series profile JSON so 0i-D's small tool table differs from 30i's; newSeries/ToolingPopulatesTests.csandSeries/OffsetsPopulatesTests.csintegration tests; updatedocs/drivers/FOCAS-Test-Fixture.mdcoverage 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). ProjectMessages/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
\0or space). - Docs / fixture / e2e: add
Messages/ExternalandProgram/CurrentBlockrows to the fixed-tree table indocs/drivers/FOCAS.md, including the ring-buffer / last-N semantics for opmsg; document theOPMSG3andODBACT2payload shapes indocs/v2/implementation/focas-wire-protocol.md; addcnc_rdopmsg3/cnc_rdactptrows to the protocol surface table indocs/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 amock_patch_opmsgadmin endpoint hook onFocasSimFixturefor tests that need to push a canned message; integration testSeries/OperatorMessagesPopulateTests.csasserts 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_getfigurereturns per-axis decimal-place counts; cache the result at bootstrap and divide eachAbsolutePosition/MachinePosition/RelativePosition/DistanceToGo/ActualFeedRatevalue before publishing. Existing nodes already carryFloat64; the change is invisible to clients except that values become real-world units. AddsDiagnostics/subtree:Diagnostics/ReadCount,Diagnostics/ReadFailureCount,Diagnostics/LastErrorMessage,Diagnostics/LastSuccessfulRead,Diagnostics/ReconnectCount— driven by counters already maintained onDeviceState. - Files:
Wire/FocasWireClient.cs(newReadFigureAsync),IFocasClient.cs,FocasDriver.cs(cache decimal places per axis, multiply on the read path, expose counters underDiagnostics/). - Tests: assert that with a canned
cnc_getfigurereturning 3, anAbsolutePositionof 12345 becomes12.345. Connection-stat tests assert counters increment under known conditions. - Docs / fixture / e2e: significant
docs/drivers/FOCAS.mdchange — add a "Decimal-place scaling" subsection explaining theFixedTree.ApplyFigureScalingflag (default true on new installs, false on migrations) and the unit-correctness semantics it enforces; addDiagnostics/*rows to the fixed-tree table; add a Diagnostics-counters subsection todocs/v2/focas-deployment.mdfor operator dashboards; documentcnc_getfigure(ODBAXDP/ODBAXIS) struct indocs/v2/implementation/focas-wire-protocol.md; addcnc_getfigureto the protocol surface indocs/v2/implementation/focas-simulator-plan.md; extend focas-mock with the per-axis decimal-place command handler + adecimal_placesfield on each profile JSON; updatedocs/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-CheckDecimalScalingswitch toscripts/e2e/test-focas.ps1that asserts AbsolutePosition is scaled when the flag is on; integration testSeries/DecimalScalingTests.csandSeries/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.ApplyFigureScalingopt-in flag (default true on new installs, false when migrating); document indocs/drivers/FOCAS.md.
Phase 2 — addressing additions
PR F2-a — DIAG: address scheme (#14)
- Scope: new
FocasAreaKind.Diagnosticparsed fromDIAG:nnn/DIAG:nnn/axis, dispatched tocnc_rddiag(orcnc_rddiagdgnfor series that support it). - Files:
FocasAddress.cs(new prefix branch),FocasCapabilityMatrix.cs(newDiagnosticRangeper 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 indocs/Driver.FOCAS.Cli.mdwithread -a DIAG:301andDIAG:301/0(axis-scoped) examples; add aDIAG:row to the addressing table indocs/drivers/FOCAS.md; add per-seriesDiagnosticRangecolumns todocs/v2/focas-version-matrix.md; document theODBDGNstruct indocs/v2/implementation/focas-wire-protocol.md; addcnc_rddiag/cnc_rddiagdgnto the protocol surface indocs/v2/implementation/focas-simulator-plan.md; extend focas-mock with the diagnostic-range command handler + per-profile seeded diagnostic numbers; integration testSeries/DiagAddressTests.csround-trips a seeded diagnostic number; updatedocs/drivers/FOCAS-Test-Fixture.mdcapability list with the newDiagnosticFocasAreaKind. - Effort: medium.
- Risk: Low — additive.
PR F2-b — Multi-path / multi-channel CNC (#4)
- Scope: 30i/31i/32i can host 2–10 paths; today every request block is
built with
PathId = 1(Wire/FocasWireProtocol.cs:216). Add optionalPathsegment toFocasAddress(e.g.PARAM:1815@2,R100@3.0,MACRO:500@2); thread it into theRequestBlock.PathIdfield. Fixed-tree gets aPaths/{n}/folder pivot. - Files:
FocasAddress.cs(newPathfield + parser),IFocasClient.cs(every read call gains an optionalpathIdparameter, defaulting to 1 for backward compatibility),Wire/FocasWireClient.cs(thread the param through everyRequestBlockconstructor),FocasDriver.cs(per-devicePathCountdiscovery viacnc_rdpathnum; iterate fixed-tree per path). - Tests: unit on the parser; simulator with two paths configured;
assert that a
PARAM:1815@2read targets path 2. - Docs / fixture / e2e: significant
docs/drivers/FOCAS.mdupdate — new "Multi-path / multi-channel CNC" subsection explaining the@Nsuffix syntax,Paths/{n}/browse pivot, and per-path capability gating; add@Nto every address row in the addressing table indocs/Driver.FOCAS.Cli.md; documentcnc_rdpathnum(ODBPATHNUMstruct) indocs/v2/implementation/focas-wire-protocol.md, and update theRequestBlock.PathIddiscussion (was hard-coded to 1 — now a parameter); addcnc_rdpathnumto the protocol surface and the per-profilepath_countfield to the profile schema indocs/v2/implementation/focas-simulator-plan.md; extend focas-mock with per-path state isolation (separate PMC / param / macro tables perpath_id) and a newmulti_pathprofile (e.g.thirtyone_i_dual_path); add a-Pathsswitch toscripts/e2e/test-focas.ps1that runs the matrix once per declared path; document the new compose profile indocs/drivers/FOCAS-Test-Fixture.md; newSeries/MultiPathTests.csintegration test asserting independent per-path reads. - Effort: large — touches every wire call's
RequestBlockshape. - Risk: Medium — backward compatibility for existing single-path
configs. Default
PathId = 1everywhere; only deviate when the address explicitly carries a@Nsuffix 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 addresspmc_rdpmcrngnumeric letter codes match. - Files:
FocasCapabilityMatrix.cs(one-line fix to the 16i case),tests/.../FocasCapabilityMatrixTests.cs(assert F0.0 and G50.5 parse againstSixteen_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 intests/.../Docker/focas-mock/profiles/sixteen_i.jsonalready has F/G ranges declared from Stream B); add F0.0 / G50.5 probes to the 16i row of the per-series matrix inscripts/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_rdpmcrngper 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 intoFocasDriver.ReadAsyncbetween the per-tag path and the wire call layer. Surface coalesce stats on theDiagnostics/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 newDiagnostics/CoalesceStats/*counters added on top of PR F1-f's diagnostics tree; add a PMC-byte-cap column todocs/v2/focas-version-matrix.md; no new wire calls (pmc_rdpmcrngis already in the surface), but document the supported max-bytes-per-call indocs/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 viaFocasSimFixture.GetWireCallCountAsync); updatedocs/v2/implementation/focas-simulator-plan.mdStream B validation harness with the request-counter handler; integration testSeries/PmcCoalescingTests.csasserts anR100..R110batch produces exactly 1 wire call against the mock. - Effort: medium.
- Risk: Medium — the FANUC max-bytes-per-
pmc_rdpmcrngceiling 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
FocasAlarmProjectionwith two modes —ActiveOnly(today's behaviour) andActivePlusHistory. In the latter, on connect (and on a configurable cadence — default 5 min, since the CNC ring buffer changes only on alarm raise/clear) issuecnc_rdalmhistryfor the most-recent N entries; project as historic events throughIAlarmSourcewithOccurrenceTimefrom the CNC's timestamp field. - Files: new
Wire/FocasWireClient.ReadAlarmHistoryAsync, newIFocasClient.ReadAlarmHistoryAsync,FocasAlarmProjection.cs(mode switch + history poll loop),FocasDriverOptions.cs(AlarmProjection.Modeenum +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.mddocumenting theActiveOnlyvsActivePlusHistorymode switch, theHistoryDepthcap, and the dedup key; add a configuration-knob row todocs/v2/focas-deployment.mdfor operator dashboards; documentODBALMHISstruct indocs/v2/implementation/focas-wire-protocol.md; addcnc_rdalmhistryto the protocol surface indocs/v2/implementation/focas-simulator-plan.md; extend focas-mock with a ring-buffer alarm history (per profile) +mock_patch_alarmhistoryadmin endpoint; expose aSeedAlarmHistoryAsynchelper onFocasSimFixture; addSeries/AlarmHistoryProjectionTests.csasserting historic events fire once and active events still fire raise/clear; updatedocs/drivers/FOCAS-Test-Fixture.mdintegration bullet list withcnc_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
BadNotWritableshort-circuit inWireFocasClient.WriteAsyncand replace with a kind-based dispatch that returnsBadNotWritableonly for kinds the wire client doesn't yet implement. HonourFocasTagDefinition.Writable(already present, defaulttrue— flip default tofalseper #1's safer posture). PlumbWriteIdempotentthrough Polly retry. - Files:
WireFocasClient.cs,FocasDriverOptions.cs,FocasDriver.cs,docs/drivers/FOCAS.md(rewrite the read-only paragraph), newdocs/v2/decisions.mdentry. - Tests: assert that with
Writable=falsethe path still returnsBadNotWritable; withWritable=trueand an unimplemented kind the write returnsBadNotSupported(distinct from the per-tag policy denial). - Docs / fixture / e2e: this is the heaviest doc PR in the plan.
docs/drivers/FOCAS.mdlines 14–18 — 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 namesWrites.Enabled, the per-tagWritableflag (default flipped tofalse), and links to the Phase 4 decision-record entry.docs/drivers/FOCAS-Test-Fixture.mdlines 42–43 — revoke the "IWritableintentionally returnsBadNotWritable— 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.mdlines 100–116 — the existingwritesection already documents the CLI shape; expand the "Writes are non-idempotent by default" warning with a server-side note that the OtOpcUa endpoint enforces theWrites.Enabledflag 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.mdentry "FOCAS write-path opt-in" capturing the design-choice reversal. - Update
docs/featuregaps.mdrow 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
BadNotSupporteduntil F4-b lands). - E2E: add
-Writeswitch (no-op stage in this PR; populated by F4-b) toscripts/e2e/test-focas.ps1.
- Effort: medium.
- Risk: High — design-choice reversal. Mitigation: ship behind a
driver-level
Writes.Enabledflag (defaultfalse); operators must explicitly enable inappsettings.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(newWriteParameterAsync,WriteMacroAsync),WireFocasClient.WriteAsync(dispatch). - Tests: simulator extension — accept writes and reflect them on
subsequent reads. ACL tests in
tests/ZB.MOM.WW.OtOpcUa.IntegrationTeststo verify the server-layer enforcement (per the memory entry: ACL decisions happen inDriverNodeManager, 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, theWrites.AllowParameterandWrites.AllowMacrogranular flags, and a security note: parameter writes require LDAP groupWriteConfigure, macro writes requireWriteOperate(cross-link todocs/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 existingwriteexamples forPARAM:andMACRO:(already present at lines 105–108) with a "Server-enforced ACL" note linking todocs/Security.md.- Document
IODBPSD(write side) andODBM(write side) indocs/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— addcnc_wrparam/cnc_wrmacroto the protocol surface table and update Stream C status accordingly.- Extend focas-mock with
cnc_wrparam/cnc_wrmacrohandlers that mutate the per-profile state and returnEW_PASSWDwhen the unlock state is off (sets up F4-d's test path); addmock_get_last_writeadmin endpoint for audit-log assertions. - New
Series/ParameterWriteTests.csandSeries/MacroWriteTests.csintegration tests; ACL test undertests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.csassertingWriteConfigureis required forPARAM:writes andWriteOperateforMACRO:writes. scripts/e2e/test-focas.ps1— populate the-Writestage 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.AllowParameterflag separate fromWrites.Enabledso 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(newWritePmcRangeAsync), bit-level RMW helper inWireFocasClient. - 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 newWrites.AllowPmcgranular 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 theDiagnostics/CoalesceStats/(extended) tree.docs/Driver.FOCAS.Cli.md— the existingwriteexamplewrite -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_wrpmcrngrequest frame indocs/v2/implementation/focas-wire-protocol.md(the read frame is already there — flag the inverted shape). docs/v2/implementation/focas-simulator-plan.md— addpmc_wrpmcrngto the protocol surface table.- Extend focas-mock with
pmc_wrpmcrnghandler that mutates per-profile PMC tables; assert byte-aligned writes preserve untouched bytes (mirrors the driver's RMW contract). - New
Series/PmcRangeWriteTests.csandSeries/PmcBitRmwIntegrationTests.csintegration tests; ACL test undertests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasPmcWriteAclTests.csassertingWriteOperateis required. scripts/e2e/test-focas.ps1— extend the-Writestage 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_wrparamand certain reads behind a connection-level password. AddPasswordtoFocasDeviceOptions; emit the FOCAS password block during connect (cnc_wrunlockparamper FOCAS docs — confirm the exact command id during simulator iteration). On any read/write returningEW_PASSWDre-issue the password and retry once. - Files:
Wire/FocasWireClient.cs(UnlockAsync),FocasDriverOptions.cs(Passwordfield, treated as a secret — redact in logs),FocasDriver.cs(call on connect). - Tests: simulator extension — emit
EW_PASSWDon writes when not unlocked; assert the unlock+retry path. - Docs / fixture / e2e:
docs/drivers/FOCAS.md— new "FOCAS password" subsection under Writes describing the optionalPassworddevice-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 inappsettings.json(and the dev redaction pattern at.local/), the no-log invariant, and a runbook for password rotation. Cross-link todocs/Security.md. docs/Driver.FOCAS.Cli.md— add a--cnc-passwordflag row to the "Common flags" table with the redaction note.- Document
cnc_wrunlockparam(or the resolved command id) indocs/v2/implementation/focas-wire-protocol.md; resolve the open question raised by F4-d into the doc. docs/v2/implementation/focas-simulator-plan.md— addcnc_wrunlockparamto the protocol surface; document the per-profileunlock_passwordfield on the JSON profile schema.- Extend focas-mock with locked-state semantics on parameter
writes (already half-stubbed in F4-b's
EW_PASSWDbranch); addcnc_wrunlockparamhandler; addmock_set_passwordadmin endpoint so integration tests can pin the unlock value. - New
Series/PasswordUnlockTests.csintegration test asserts a write returningEW_PASSWDtriggers exactly one unlock retry, and the second write succeeds. scripts/e2e/test-focas.ps1— add-CncPasswordparameter, threaded through to the CLI for the-Writestage.
- Effort: small — once Phase 4-a/b are in.
- Risk: Medium — password storage. Use the existing
appsettings.jsonredaction pattern (memory entry:dohertj2AppData 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/CycleTimeSecondsin place from F1-b and the parts-count fromcnc_rdparam, compute "last completed cycle" as the delta inTimers/CycleSecondsbetween successive parts-count increments. ProjectProduction/LastCycleSeconds,Production/LastCycleStartUtc. - Files:
FocasDriver.csonly — pure derivation in the program-poll cadence handler. - Tests: simulate a parts-count increment from 5→6; assert
LastCycleSecondsequals the cycle-timer delta over the same window. - Docs / fixture / e2e: add
Production/LastCycleSecondsandProduction/LastCycleStartUtcrows to the fixed-tree table indocs/drivers/FOCAS.mdwith the rollover / counter-reset behaviour documented; add aDerived telemetrycallout indocs/v2/focas-deployment.mdexplaining the derivation is client-visible only (no new wire calls); nodocs/v2/implementation/focas-wire-protocol.mdchange (pure derivation); no focas-mock change beyondFocasSimFixture's existing parameter-patch / timer-patch helpers — add aSimulateCycleCompletionAsyncconvenience helper that increments parts-count and advances the cycle timer atomically; newSeries/CycleDeltaTests.csintegration test simulates a 5→6 parts-count transition; noscripts/e2e/test-focas.ps1change. - 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 14–18; 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 42–43 |
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 100–116; 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.mdlines 14–18 ("OtOpcUa is read-only against FOCAS… Writes returnBadNotWritableby design.")docs/drivers/FOCAS-Test-Fixture.mdlines 42–43 ("IWritableintentionally returnsBadNotWritable— OtOpcUa is read-only against FOCAS.")docs/Driver.FOCAS.Cli.mdlines 100–116 (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
Unknownas 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_modalnumeric 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[].OverrideRegistersmap). - 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=1flag the wire-protocol doc describes. - Decimal-scaling migration (PR F1-f): existing
Float64axis 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) vsWriteOperate(macros / PMC)? Per the memory entry on ACL-at-server-layer, the driver only reportsSecurityClassification; the server enforces. Need the driver to surface the right classification per address kind:ConfigureforPARAM:,OperateforMACRO:and PMC writes. - Phase 4 rollout: ship behind a feature flag in
appsettings.json(Drivers.{name}.Config.Writes.Enabled) withfalsedefault for at least one release before flipping the default. Updatedocs/drivers/FOCAS.mdanddocs/featuregaps.mdin 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/LastCycleSecondsasnullwithBadOutOfRangeand let the operator interpret.