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>
This commit is contained in:
Joseph Doherty
2026-04-30 08:28:01 -04:00
parent ae7106dfce
commit 2d07d716dc
33 changed files with 8074 additions and 14 deletions

682
docs/plans/abcip-plan.md Normal file
View File

@@ -0,0 +1,682 @@
# AbCip Driver — Implementation Plan
> Source of gap analysis: [featuregaps.md → AbCip](../featuregaps.md#abcip-allen-bradley-ethernetip--logix)
>
> This plan covers the **Build = Yes** items only. Skip-rated gaps are listed at the bottom for traceability.
## Summary
This plan closes the 16 Build-rated AbCip gaps in five phases ordered to ship correctness fixes
first, then engineering workflow, then performance, then operability, and finally redundancy.
Phase 1 lands the data-type fidelity work (LINT/ULINT, native STRINGnn, array slicing,
write-multi packing) that today silently truncates 64-bit values and serialises adjacent reads
into N round-trips. Phase 2 introduces the offline tag-import workflow (L5K/L5X + CSV) that
Studio 5000 shops require before they will switch off Kepware. Phase 3 exposes the
performance levers commercial drivers ship as field knobs — symbolic vs logical addressing,
configurable Connection Size, and the logical-blocking / logical-non-blocking strategy
selector. Phase 4 surfaces per-tag scan rates, write deadband, online tag-DB refresh trigger,
and the diagnostic system tags an HMI dashboard expects. Phase 5 adds HSBY paired-IP failover
for continuous-process plants. Headline outcome: parity with Kepware's Logix Database Settings
and TOP Server's protocol-mode picker, with measurable throughput wins (3-5x on dense rigs via
logical addressing, single-PDU reads on contiguous arrays, single-PDU writes on multi-tag
recipe pushes).
## Phased delivery
### Phase 1 — Data-type correctness (4 PRs)
Goal: stop silently losing data. None of the items in this phase are user-visible features —
they are correctness fixes against existing capability surfaces.
#### PR 1.1 — LINT / ULINT 64-bit fidelity
- **Scope**: replace the truncating `Int32` widening at `AbCipDataType.cs:53` with `Int64`
routing across decode + encode + the `DriverDataType` map. Includes `DT` (epoch-millis on
Logix v32+ surfaces as LINT, not DINT — verify against `LibplctagTagRuntime.cs:53` before
reusing the same code-path).
- **Files**: `AbCipDataType.cs` (mapping), `LibplctagTagRuntime.cs` (already calls
`_tag.GetInt64` / `SetInt64`, so the runtime is correct — the gap is the surface enum
flattening into `Int32`), `Core.Abstractions/DriverDataType.cs` may need an `Int64` /
`UInt64` member if not already present.
- **Test approach**: unit (xUnit + Shouldly) with a fake `IAbCipTagRuntime` that returns
`long.MaxValue` on `DecodeValueAt(LInt, ...)`; assert the snapshot value round-trips through
the read path without truncation. Integration test against pymodbus is N/A — needs a live
Logix or a libplctag mock-server fixture; keep this unit-only and rely on smoke testing on
the dev box with a real ControlLogix.
- **Effort**: S
- **Dependencies**: confirm `DriverDataType.Int64` exists; if not, that is a Core change
shared with the Modbus TODO at `AbCipDataType.cs:53`.
- **Docs / fixture / e2e**: appends a Logix-types row to the type-mapping table in
`docs/Driver.AbCip.Cli.md` (CLI gains `--type LInt` / `--type ULInt`); extends
`docs/drivers/AbServer-Test-Fixture.md` §"What it actually covers" to list `LINT` once
ab_server is reseeded with a `TestLINT:LINT[1]` tag; updates the
`tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml`
ControlLogix profile to seed `TestLINT`; adds a 64-bit assertion case in
`AbCipReadSmokeTests`; extends `scripts/e2e/test-abcip.ps1` with an LInt loopback
assertion (and a matching seeded `TestLINT` in `scripts/smoke/seed-abcip-smoke.sql`).
#### PR 1.2 — Native STRING / STRINGnn variant decoding
- **Scope**: Today `AbCipDataType.String` flattens any Logix `STRING` UDT into a .NET
string via libplctag's `_tag.GetString(0)`. Logix programs commonly define
`STRING_20`, `STRING_40`, `STRING_80` variants with different DATA-array sizes; libplctag
honours these when the tag name resolves to the user-defined type, but our discovery
emits them as the generic `String` placeholder. Add a `StringLength` field to
`AbCipStructureMember` + `AbCipTagDefinition` so declared variants carry their cap, and
thread it into the `Tag.Name` attribute or a libplctag string-cap hint.
- **Files**: `AbCipDataType.cs`, `AbCipDriverOptions.cs` (record fields), `LibplctagTagRuntime.cs`
(string-length aware decode/encode), and the discovery emit at `AbCipDriver.cs:715`.
- **Test approach**: unit test with a fake runtime returning `string` values shorter and
longer than the declared cap; integration test deferred until a sample L5X with mixed
STRING variants is available.
- **Effort**: M
- **Dependencies**: investigate libplctag's `str_max_capacity` / `str_count_word_bytes`
attributes — the docs reference them but the C# wrapper may not expose them; if not, this
PR must extend `LibplctagTagRuntime` with a raw-buffer decode path.
- **Docs / fixture / e2e**: extends `docs/Driver.AbCip.Cli.md` with a new `--string-size`
flag in the `read`/`write` cookbook plus a STRINGnn worked example; updates
`docs/drivers/AbServer-Test-Fixture.md` §"What it actually covers" to list
`STRING_20`/`STRING_80` once seeded; extends the ControlLogix profile in
`tests/.../Docker/docker-compose.yml` with `TestSTRING80:STRING[1]` (plus a `STRING_20`
variant if `ab_server` honours non-default DATA caps; otherwise documented as Emulate-tier
only); adds `tests/.../IntegrationTests/AbCipStringDecodingTests.cs` round-trip; adds a
short-string round-trip case to `scripts/e2e/test-abcip.ps1` and a `TestSTRING80` row to
`scripts/smoke/seed-abcip-smoke.sql`.
#### PR 1.3 — Array-slice read addressing `Tag[0..N]`
- **Scope**: today `AbCipTagPath` parses `Tag[3,5]` as a single element. Add slice syntax
`Tag[0..15]` (parsed in `AbCipTagPath.TryParse`) and a planner that issues one libplctag
read with `elem_count=N` per Rockwell array semantics, decoding the buffer at element
stride into N output snapshots. Mirrors the whole-UDT planner pattern.
- **Files**: `AbCipTagPath.cs` (parser — add `IsSlice` + `SliceLength` to the path segment
record, or carry it on `AbCipTagPath` itself), new `AbCipArrayReadPlanner.cs` next to
`AbCipUdtReadPlanner.cs`, `AbCipDriver.ReadAsync` to dispatch through the planner,
`IAbCipTagRuntime` to add `DecodeArrayAt(type, elementStride, count)` or build on
`DecodeValueAt`. Investigate libplctag's `elem_count` attribute on `Tag` create to confirm
the right wire-level switch.
- **Test approach**: parser unit tests for the new syntax, planner unit tests with fake
runtime, integration smoke against a live ControlLogix DINT[100] tag using the dev-box
PLC.
- **Effort**: L
- **Dependencies**: PR 1.1 must land first if the array element type is LINT — otherwise the
slice path silently truncates 64-bit elements.
- **Docs / fixture / e2e**: extends `docs/Driver.AbCip.Cli.md` `read` section with the
`Tag[0..N]` slice syntax + a worked example reading `Recipe[0..15]` in one round-trip;
updates `docs/drivers/AbServer-Test-Fixture.md` §"What it actually covers" to mention
the existing `DINT[16]` array tag is now exercised end-to-end via slicing; extends
`AbCipReadSmokeTests` with a slice-read assertion against the seeded `TestDINTArray`;
adds `tests/.../IntegrationTests/AbCipArraySliceTests.cs` covering edge cases
(boundary, single-element, full-range); adds a slice-read assertion to
`scripts/e2e/test-abcip.ps1`.
#### PR 1.4 — CIP multi-tag write packing
- **Scope**: `AbCipDriver.WriteAsync` (`AbCipDriver.cs:460-546`) loops over writes one-by-one.
Group writes by `(device, no-bit-RMW)` and submit one CIP Multi-Service Packet (0x0A)
carrying up to N write-singles per round-trip. Honours the per-family
`SupportsRequestPacking` flag at `AbCipPlcFamilyProfile.cs:36,43,51,59` — Micro800 falls
back to the existing per-write loop because its profile already disables packing.
- **Files**: `AbCipDriver.cs` (add a write planner mirroring the read planner), new
`AbCipMultiWritePlanner.cs`, possibly a new `IAbCipTagRuntime.WriteBatchAsync` method or a
new `IAbCipMultiWriter` capability since libplctag's high-level `Tag.WriteAsync` is
per-tag — investigate libplctag's `cip-msg-multi` raw-CIP path or whether building a
Multi-Service Packet via `plc_tag_create("name=@raw,...")` is feasible.
- **Test approach**: unit test the planner with a synthetic batch (mixed-device, mixed
bit-RMW, one Micro800); integration test recipe-style 50-tag write against ControlLogix
measuring round-trip count via Wireshark or via a libplctag debug-trace sink.
- **Effort**: L
- **Dependencies**: investigate libplctag multi-service-packet API; if absent, this PR may
need to drop down to raw CIP via the `@raw` pseudo-tag or be deferred.
- **Docs / fixture / e2e**: appends a "Multi-tag writes" subsection to
`docs/Driver.AbCip.Cli.md` (no flag — automatic batching when multiple writes queue
inside one publish) plus a note that Micro800 falls back per profile; updates
`docs/drivers/AbServer-Test-Fixture.md` §7 ("Capability surfaces beyond read") to flip
`IWritable.WriteAsync` from "no smoke test" to covered for the multi-write path; adds
`tests/.../IntegrationTests/AbCipMultiWriteTests.cs` asserting 50-tag batch lands in one
round-trip (count via libplctag debug-trace sink); extends `scripts/e2e/test-abcip.ps1`
with a recipe-style multi-write step; extends seed SQL with two extra DINT tags so the
e2e has a packing target.
### Phase 2 — Tag-import workflows (4 PRs)
Goal: replicate Kepware's Logix Database Settings — point the driver at an L5K/L5X export or
a CSV and have the tag table populate without an online controller.
#### PR 2.1 — L5K parser + ingest
- **Scope**: parse a Studio 5000 L5K export (a labelled-section text format with
`TAG ... END_TAG` blocks, `DATATYPE ... END_DATATYPE` UDT definitions, and program-scope
qualifiers). Produce `AbCipTagDefinition` + `AbCipStructureMember` records that match the
declarative options shape. Includes Description ingest (PR 2.3 lifts it to OPC UA
`Description`).
- **Files**: new `Import/L5kParser.cs`, new `Import/IL5kSource.cs` for testability, new
`Import/L5kIngest.cs` that converts parsed records into `AbCipTagDefinition`. Hook into
`AbCipDriverOptions` via a new `TagImports` collection (filenames or inline blobs) parsed
on `AbCipDriver.InitializeAsync`.
- **Test approach**: unit-only with sample L5K files in `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/Import/Fixtures/`
covering controller-scope tags, program-scope tags, alias tags (skipped per Kepware
precedent), and UDTs with nested structures.
- **Effort**: L
- **Dependencies**: none — pure-text parser.
- **Docs / fixture / e2e**: new doc `docs/drivers/AbCip-TagImport.md` covering the L5K
format support matrix (controller-scope / program-scope / UDT / alias-skipped) and a
worked example of pointing `AbCipDriverOptions.TagImports` at an L5K export; appends a
`tag-import` command section to `docs/Driver.AbCip.Cli.md` (CLI gains
`tag-import --file foo.L5K`); fixture-side no change to `ab_server` (offline parse —
no PLC needed) but adds sample L5K files under
`tests/.../AbCip.Tests/Import/Fixtures/`; extends `scripts/e2e/test-abcip.ps1` with an
offline `tag-import` smoke that diffs the parsed tag set against a golden JSON.
#### PR 2.2 — L5X (XML) parser + ingest
- **Scope**: same surface as PR 2.1 but parses Studio 5000's XML export. L5X is the de-facto
modern format (Studio 5000 v21+) and carries richer metadata than L5K including
ExternalAccess attributes and AOI definitions.
- **Files**: new `Import/L5xParser.cs` using `System.Xml.XPath`, share the `IL5kSource` /
`L5kIngest` ingest layer with PR 2.1 by introducing a common `ParsedTagsBundle` record.
- **Test approach**: unit tests with sample L5X fixtures including an AOI-typed tag (sets up
the AOI work in PR 2.7).
- **Effort**: L
- **Dependencies**: PR 2.1 is preferred first to settle the shared ingest seam.
- **Docs / fixture / e2e**: extends `docs/drivers/AbCip-TagImport.md` (created in PR 2.1)
with the L5X-specific section — namespace handling, ExternalAccess attributes, AOI
references; extends the `tag-import` CLI section in `docs/Driver.AbCip.Cli.md` to note
L5X auto-detection by file extension; sample L5X files added under
`tests/.../AbCip.Tests/Import/Fixtures/` (one with an AOI-typed tag for PR 2.6);
reuses the offline `tag-import` step from `scripts/e2e/test-abcip.ps1` (now driven by
L5X) — no fixture container change because parse is offline; cross-links from
`tests/.../IntegrationTests/LogixProject/README.md` so the on-site Emulate L5X export
doubles as a parser fixture.
#### PR 2.3 — Tag descriptions surfaced as OPC UA `Description`
- **Scope**: extend `AbCipTagDefinition` with `Description` (string?), populate it from the
L5K/L5X parsers, and thread it through to `DriverAttributeInfo` so the address-space
builder sets the OPC UA `Description` attribute. Also lifts the description onto
`AbCipStructureMember` for member-level metadata.
- **Files**: `AbCipDriverOptions.cs` (record fields), `AbCipDriver.cs:760-770`
(`ToAttributeInfo` helper), `Core.Abstractions/DriverAttributeInfo.cs` (verify it carries
a Description field; if not, that becomes a Core PR shared across drivers).
- **Test approach**: unit — a discovery test asserts that a tag with a description ends up
with that description on the `DriverAttributeInfo` record.
- **Effort**: S
- **Dependencies**: PR 2.1 / PR 2.2 (descriptions only land via importer).
- **Docs / fixture / e2e**: appends a "Description metadata" subsection to
`docs/drivers/AbCip-TagImport.md` documenting how Studio 5000 descriptions surface as
OPC UA `Description`; no CLI surface change (read-side only — the existing
`otopcua-cli read` already projects `Description`); no fixture container change; adds
a cross-driver assertion to the existing OPC UA browse test in
`tests/.../IntegrationTests/` verifying the description survives the full
parser → driver → server → client path; extends `scripts/e2e/test-abcip.ps1` with a
one-line `Description != null` assertion after the import smoke step.
#### PR 2.4 — CSV tag import / export
- **Scope**: a CSV round-trip matching the Kepware column layout (`Tag Name, Address, Data
Type, Respect Data Type, Client Access, Scan Rate, Description, Scaling`). Import populates
`AbCipTagDefinition`; export dumps the live tag table for editing in Excel.
- **Files**: new `Import/CsvTagImporter.cs`, new `Import/CsvTagExporter.cs`, integration
point in `AbCipDriverOptions.TagImports` parallel to PR 2.1's hook. Export hook is
exposed via the CLI (`docs/Driver.AbCip.Cli.md`) — add a `tag-export` command.
- **Test approach**: unit tests for parser + writer with fixture CSVs; CLI integration test
using a synthetic options payload.
- **Effort**: M
- **Dependencies**: lighter than 2.1/2.2 — could ship in either order, but landing CSV after
L5X means the CSV export reuses the `ParsedTagsBundle` shape.
- **Docs / fixture / e2e**: appends a "CSV tag table" section to
`docs/drivers/AbCip-TagImport.md` documenting the column layout (Kepware-compatible) and
round-trip semantics; appends `tag-export` and CSV-flavour `tag-import` commands to
`docs/Driver.AbCip.Cli.md`; adds sample CSVs under
`tests/.../AbCip.Tests/Import/Fixtures/` plus a CLI integration test
(`tests/.../AbCip.Tests/Import/CsvRoundTripTests.cs`); extends
`scripts/e2e/test-abcip.ps1` with an export-then-import-then-diff scenario (no PLC
required); fixture-side no change.
#### PR 2.5 — Online tag-DB refresh trigger (`$Sys$UpdateTagInfo` parity)
- **Scope**: AVEVA exposes `$Sys$UpdateTagInfo` so an HMI can write `1` to force the driver
to re-walk the controller's symbol table after a Studio 5000 download — without restarting
the driver. Implement as a new `IDriverControl.RebrowseAsync()` invoked by the server or
via a system-tag write (PR 4.4 will surface system tags as browseable variables — once
that lands, this becomes the writeable system tag `_RefreshTagDb`). For now expose it
via the CLI and via a new `AbCipDriver.RebrowseAsync` method.
- **Files**: `AbCipDriver.cs` (new method that re-runs the `@tags` enumerator without going
through full `ReinitializeAsync`), CLI command in `src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/`,
documentation update in `docs/Driver.AbCip.Cli.md`.
- **Test approach**: unit test that two consecutive `RebrowseAsync` calls produce two
enumeration passes; integration smoke against the dev-box ControlLogix verifying the
address space picks up a tag added between rebrowses.
- **Effort**: M
- **Dependencies**: ties cleanly into PR 4.4 (system tags) but ships earlier as a
programmatic API.
- **Docs / fixture / e2e**: appends a `rebrowse` command to `docs/Driver.AbCip.Cli.md`
with a Studio 5000 download recipe ("after a download, run
`otopcua-abcip-cli rebrowse -g …`"); cross-references the future `_RefreshTagDb` system
tag once PR 4.4 lands; updates `docs/drivers/AbServer-Test-Fixture.md` §7 to mark
`ITagDiscovery.DiscoverAsync` as covered for the rebrowse path; adds
`tests/.../IntegrationTests/AbCipRebrowseTests.cs` driving two consecutive
enumerations (the second sees a tag added between calls — ab_server supports runtime
reseed via its REST hook); extends `scripts/e2e/test-abcip.ps1` with a
rebrowse-after-reseed assertion (or marks it `[OnlyIfRig]` if the simulator's reseed
hook isn't reachable).
#### PR 2.6 — AOI (Add-On Instruction) input/output handling
- **Scope**: AOIs are first-class types in L5X (`AddOnInstructionDefinition` blocks). The
Template Object decoder at `CipTemplateObjectDecoder.cs` likely already handles them at
the wire level (an AOI is a Logix UDT with InOut/Input/Output qualifiers). This PR adds:
(a) AOI-aware browse paths so an AOI instance shows up as a folder with `Inputs/`,
`Outputs/`, `InOut/` sub-folders; (b) skip-on-discovery for `InOut` parameters per
Kepware's documented limitation (InOut is a pointer, not a value).
- **Files**: extend `AbCipStructureMember` with an `AoiQualifier` enum
(Input/Output/InOut/Local), L5K/L5X parser extends to set it, `AbCipDriver.DiscoverAsync`
groups members into qualifier-named sub-folders.
- **Test approach**: unit test discovery against an AOI-containing fixture.
- **Effort**: M
- **Dependencies**: PR 2.2 (L5X) lands the AOI definition parsing.
- **Docs / fixture / e2e**: appends an "AOI handling" section to
`docs/drivers/AbCip-TagImport.md` covering Inputs/Outputs/InOut grouping + the InOut
skip rationale; updates `docs/drivers/AbServer-Test-Fixture.md` §"What it does NOT
cover" to keep AOIs flagged as ab_server-blocked but call out Logix Emulate as the
authoritative tier; adds a sample AOI-bearing L5X under
`tests/.../AbCip.Tests/Import/Fixtures/` and a discovery test that asserts the
Inputs/Outputs sub-folder shape; promotes
`tests/.../IntegrationTests/Emulate/AbCipEmulateAoiTests.cs` (gated on
`AB_SERVER_PROFILE=emulate`) — no `scripts/e2e/test-abcip.ps1` change because AOIs
need Emulate or a rig.
### Phase 3 — Performance levers (3 PRs)
Goal: expose the protocol-mode + connection-tuning knobs that commercial drivers expose as
device-level config.
#### PR 3.1 — Configurable CIP Connection Size per device
- **Scope**: today the family profile hard-codes 4002 / 504 / 488 at
`AbCipPlcFamilyProfile.cs:33,42,49`. Add an optional `ConnectionSize` field to
`AbCipDeviceOptions` that overrides the family default; thread it through to the
libplctag tag-create attribute (`connection_size=N`). Validate against a sensible range
(500-4002 per Kepware's slider).
- **Files**: `AbCipDriverOptions.cs:70-73` (extend `AbCipDeviceOptions` record),
`IAbCipTagRuntime.cs` (extend `AbCipTagCreateParams` with `ConnectionSize`),
`LibplctagTagRuntime.cs` (set the `Tag.PlcType`-adjacent attribute — investigate libplctag
C# wrapper's exposure of `connection_size`; may need to set via `Tag.AddAttribute` if a
named property doesn't exist).
- **Test approach**: unit test that custom Connection Size flows from options into the
`AbCipTagCreateParams`; integration smoke against the dev-box ControlLogix verifying
reduced-size connections succeed on legacy v19 firmware. Live test required because
libplctag rejects out-of-range values silently in some versions.
- **Effort**: S
- **Dependencies**: investigate libplctag `connection_size` attribute exposure.
- **Docs / fixture / e2e**: appends a "Connection Size" subsection to a new
`docs/drivers/AbCip-Performance.md` (consolidates the Phase 3 knobs in one place) and a
brief note + warning-symptom callout in `docs/Driver.AbCip.Cli.md` for the new
per-device option in the Driver config; updates
`docs/drivers/AbServer-Test-Fixture.md` §5 (CompactLogix narrow cap) noting that
ab_server still doesn't enforce the cap so live coverage stays Emulate/rig-only;
extends `scripts/smoke/seed-abcip-smoke.sql` with a `ConnectionSize` field demo;
no `scripts/e2e/test-abcip.ps1` change (boot-time config knob, no per-call surface).
#### PR 3.2 — Symbolic vs logical (instance-ID) addressing toggle
- **Scope**: libplctag exposes `use_connected_msg=1&allow_packet_response_packing=1&logical_segment=1`
(or similar — investigate the exact attribute name) for instance-ID addressing that skips
per-poll ASCII parsing. Add a per-device `AddressingMode` enum
(`Symbolic | Logical | Auto`) and thread it through `AbCipTagCreateParams`. `Auto` is the
default and matches today's behaviour; `Logical` flips libplctag into instance-ID mode.
Logical mode requires a one-time symbol-table walk to map names to instance IDs — reuse
`LibplctagTagEnumerator` for the bootstrap.
- **Files**: `AbCipDriverOptions.cs` (per-device enum), `IAbCipTagRuntime.cs`
(`AbCipTagCreateParams.AddressingMode`), `LibplctagTagRuntime.cs` (translate to libplctag
attributes), `AbCipDriver.cs` (run a one-time symbol-walk on first read in Logical mode).
- **Test approach**: unit test attribute construction; integration benchmark — read 1000
tags in Symbolic vs Logical and assert >2x throughput on the dev-box ControlLogix.
- **Effort**: L
- **Dependencies**: investigate libplctag's instance-ID API; the mapping pseudo-tag is
`@tags` (already used for browse) but the per-tag wire flag needs research. If libplctag
doesn't expose this cleanly, the PR drops down to the raw `cip_addr` attribute.
- **Docs / fixture / e2e**: appends an "Addressing mode" section to
`docs/drivers/AbCip-Performance.md` (Symbolic / Logical / Auto trade-offs); adds a
per-device `addressing-mode` knob to `docs/Driver.AbCip.Cli.md` (CLI gains
`--addressing-mode` on `read`/`subscribe`/`write` for ad-hoc benchmarking); updates
`docs/drivers/AbServer-Test-Fixture.md` §"What it actually covers" to add Logical-mode
reads if ab_server's symbol table walks correctly under instance IDs (otherwise
marked Emulate-tier-only); adds a benchmark test
`tests/.../IntegrationTests/AbCipAddressingModeBenchTests.cs`; extends
`scripts/e2e/test-abcip.ps1` with a Symbolic-vs-Logical sanity assertion
(read 1000 tags both modes, assert Logical >= Symbolic throughput).
#### PR 3.3 — Logical-blocking / non-blocking strategy selector
- **Scope**: TOP Server names two modes: "logical-blocking" (whole-UDT read, decode members
in-memory) and "logical-non-blocking" (per-member reads packed into one Multi-Service
Packet). We have one direction shipped via `AbCipUdtReadPlanner`. Add a per-device
`ReadStrategy` enum with three values: `WholeUdt` (current behaviour), `MultiPacket`
(new: use libplctag request-packing to bundle per-member reads into one PDU when the UDT
is sparse — i.e. only 2-of-50 members subscribed), and `Auto` (planner picks based on
fraction-of-members-subscribed threshold). Strategy is per-device because Micro800
doesn't support packing.
- **Files**: `AbCipDriverOptions.cs` (per-device enum), `AbCipUdtReadPlanner.cs` (add the
threshold heuristic), new `AbCipMultiPacketReadPlanner.cs`, `AbCipDriver.ReadAsync`
dispatch. Honours `AbCipPlcFamilyProfile.SupportsRequestPacking` at the family level so a
user-selected `MultiPacket` on Micro800 falls back to per-tag with a warning logged.
- **Test approach**: unit test the heuristic on synthetic batches of varying sparsity;
integration benchmark with a 50-member UDT where 5 members are subscribed — verify
MultiPacket beats WholeUdt by buffer-size delta.
- **Effort**: L
- **Dependencies**: PR 1.4 (multi-tag write packing) builds the same libplctag-multi-service
primitive; landing 1.4 first reduces scope here.
- **Docs / fixture / e2e**: appends a "Read strategy" section to
`docs/drivers/AbCip-Performance.md` covering WholeUdt / MultiPacket / Auto plus the
sparsity-threshold heuristic; updates `docs/drivers/AbServer-Test-Fixture.md` §1
(UDT coverage) with a note that strategy switching is decided in the planner and
unit-tested only — Emulate is the authoritative wire-level coverage; adds
`tests/.../IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs` (gated on
`AB_SERVER_PROFILE=emulate`); no CLI surface change beyond the existing
per-device option, no `scripts/e2e/test-abcip.ps1` change because the simulator
doesn't differentiate the two strategies on the wire.
### Phase 4 — Operability (4 PRs)
Goal: make the driver behave like a SCADA driver — per-tag scan rates, write deadband,
diagnostic system tags, online refresh trigger.
#### PR 4.1 — Per-tag scan rate / scan group bucketing
- **Scope**: today subscriptions key on a single `publishingInterval` per
`_poll.Subscribe(...)` call. Add an optional `ScanRate` field to `AbCipTagDefinition` that,
when set, overrides the subscription interval for that tag. The shared `PollGroupEngine`
already buckets by interval — the change is to read the per-tag rate at subscribe-time
and place the tag into its own bucket.
- **Files**: `AbCipDriverOptions.cs` (record field), `AbCipDriver.SubscribeAsync` (look up
per-tag override before passing to `_poll.Subscribe`). `PollGroupEngine` may need a new
`Subscribe(tags, defaultInterval, perTagOverrides)` overload — check Core for the current
signature.
- **Test approach**: unit test that two tags with different ScanRate values produce two
poll buckets; integration test verifying the faster-rate tag publishes more frequently
than the slower-rate tag inside one subscription.
- **Effort**: M
- **Dependencies**: may require a small change to `PollGroupEngine` in Core.
- **Docs / fixture / e2e**: new doc `docs/drivers/AbCip-Operability.md` (consolidates the
Phase 4 knobs); appends a "Per-tag scan rate" section to it covering Kepware "scan
classes" parity + the OPC UA publishing-interval interaction; no CLI surface change;
fixture-side no change to ab_server; adds
`tests/.../IntegrationTests/AbCipPerTagScanRateTests.cs` driving two tags at
different rates against ab_server and asserting bucket separation; extends
`scripts/e2e/test-abcip.ps1` with a two-tag subscribe-rate-divergence assertion.
#### PR 4.2 — Write deadband / write-on-change
- **Scope**: `AbCipDriver.WriteAsync` writes every request through. Add per-tag
`WriteDeadband` (numeric) and `WriteOnChange` (boolean). When set, the driver tracks the
last successfully-written value per `(tag, deviceHostAddress)` and suppresses the next
write if `|new - last| < deadband` (numeric) or `new == last` (any). Suppressed writes
return `Good` so OPC UA semantics are unaffected.
- **Files**: `AbCipDriverOptions.cs` (record fields), new `AbCipWriteCoalescer.cs` holding
the per-tag last-value cache, `AbCipDriver.WriteAsync` consults the coalescer before
hitting the runtime.
- **Test approach**: unit tests with synthetic writes — assert that a sequence of jittery
setpoint values within deadband triggers a single PLC write.
- **Effort**: M
- **Dependencies**: none.
- **Docs / fixture / e2e**: appends a "Write deadband / write-on-change" section to
`docs/drivers/AbCip-Operability.md` with a worked setpoint-jitter example; updates
`docs/drivers/AbServer-Test-Fixture.md` §7 to flip the multi-write coverage line to
also cover suppression; adds
`tests/.../IntegrationTests/AbCipWriteDeadbandTests.cs` driving a jittery setpoint and
asserting the actual PLC write count via libplctag debug-trace; extends
`scripts/e2e/test-abcip.ps1` with a write-coalesce assertion (write the same value
twice, verify only one PLC-side change).
#### PR 4.3 — Diagnostic / system tags as browseable variables
- **Scope**: surface the `IHostConnectivityProbe` + `DriverHealth` data as browseable OPC UA
variables under `AbCip/<device>/_System/`. Variables: `_ConnectionStatus`, `_ScanRate`
(current effective publishing interval), `_TagCount`, `_DeviceError`, `_LastScanTimeMs`.
Read-only; updated on each driver health transition.
- **Files**: `AbCipDriver.DiscoverAsync` (`AbCipDriver.cs:674-758`) emits the system folder
per device; new `AbCipSystemTagSource.cs` produces the live values; `ReadAsync` routes
`_System/...` references to the source instead of the libplctag runtime.
- **Test approach**: unit test that the discovery emits the expected nodes; unit test that
reading a system tag returns the current health snapshot.
- **Effort**: M
- **Dependencies**: none, but PR 2.5 (online refresh trigger) becomes nicer once this lands —
`_RefreshTagDb` writes `1` to invoke `RebrowseAsync`.
- **Docs / fixture / e2e**: appends a "System tags / `_System` folder" section to
`docs/drivers/AbCip-Operability.md` enumerating `_ConnectionStatus`, `_ScanRate`,
`_TagCount`, `_DeviceError`, `_LastScanTimeMs`; cross-link from
`docs/Driver.AbCip.Cli.md` (the `read` cookbook gains a system-tag example); updates
`docs/drivers/AbServer-Test-Fixture.md` §7 to flip `IHostConnectivityProbe` state-
transition coverage from "no" to covered (system tag observation provides the assertion
hook); adds `tests/.../IntegrationTests/AbCipSystemTagDiscoveryTests.cs`; extends
`scripts/e2e/test-abcip.ps1` with a `_System/_ConnectionStatus` browse-and-read step.
#### PR 4.4 — Online tag-DB refresh trigger as `_RefreshTagDb` system tag
- **Scope**: thin follow-up to PR 2.5 + PR 4.3 — wire the writeable system tag to the
existing `RebrowseAsync` method.
- **Files**: `AbCipSystemTagSource.cs` (writeable variable), `AbCipDriver.WriteAsync`
intercepts `_RefreshTagDb` writes and dispatches to `RebrowseAsync`.
- **Test approach**: unit + CLI integration.
- **Effort**: S
- **Dependencies**: PR 2.5 and PR 4.3.
- **Docs / fixture / e2e**: extends the `_System` table in
`docs/drivers/AbCip-Operability.md` to mark `_RefreshTagDb` as writeable; appends a
"Refreshing the tag DB" recipe to `docs/Driver.AbCip.Cli.md` that pairs the system-tag
write with the existing `rebrowse` command from PR 2.5; reuses the
`AbCipRebrowseTests` fixture from PR 2.5 with an added system-tag-write entry point;
extends `scripts/e2e/test-abcip.ps1` with a `_RefreshTagDb` write-then-verify
assertion (chained off the rebrowse step from PR 2.5).
### Phase 5 — Redundancy (2 PRs)
Goal: HSBY paired-IP failover for continuous-process plants. Heavier than the rest because
it changes the `(device, hostName)` axiom — one logical device now has two host addresses.
#### PR 5.1 — Paired host address syntax + role probing
- **Scope**: extend `AbCipDeviceOptions` with `PartnerHostAddress` (optional). When set, the
device probes both gateways concurrently using the existing probe loop machinery
(`AbCipDriver.cs:235-281`). A ControlLogix HSBY pair exposes
`WallClockTime`/`Module.Status` tags that identify the active chassis — investigate the
exact tag name; `WallClockTime.SyncStatus` is one option, `S:34` (Module Status)
carries the role bit on some versions.
- **Files**: `AbCipDriverOptions.cs` (extend `AbCipDeviceOptions`), `AbCipDriver.cs`
(extend `DeviceState` with `ActiveAddress` field, run two probe loops), new
`AbCipHsbyRoleProber.cs` reading the role tag and returning Active/Standby.
- **Test approach**: unit test with two fake probe runtimes returning different role bits;
integration test deferred until a true HSBY pair is available — note in
`MEMORY.md/project_aveva_platform_installed.md` that the dev box has a single chassis.
- **Effort**: L
- **Dependencies**: investigate the canonical HSBY role tag — the AVEVA ABCIP docs name it
but the wire-level tag varies by firmware.
- **Docs / fixture / e2e**: new doc `docs/drivers/AbCip-HSBY.md` covering the paired-IP
config, the role-tag detection matrix (v20 / v24 / v32+), and the feature-flag gate
(`Redundancy.Hsby.Enabled`); extends `docs/Driver.AbCip.Cli.md` with a `--partner`
flag plus an `hsby-status` command that prints the active partner; updates
`docs/drivers/AbServer-Test-Fixture.md` §"What it does NOT cover" with a new entry
marking HSBY as ab_server-blocked but adds a "paired-fixture" mode to
`tests/.../Docker/docker-compose.yml` (two `controllogix` services on different
ports + a `hsby-mux` sidecar that flips the role bit on demand); adds
`tests/.../IntegrationTests/AbCipHsbyRoleProberTests.cs`; no
`scripts/e2e/test-abcip.ps1` change yet — HSBY e2e is gated behind a sibling
`scripts/e2e/test-abcip-hsby.ps1` script introduced in PR 5.2.
#### PR 5.2 — Failover routing in IPerCallHostResolver
- **Scope**: `AbCipDriver.ResolveHost` returns the device's primary address today
(`AbCipDriver.cs:307-312`). Change it to return the currently-Active partner. On role
transition, the existing bulkhead/breaker per-host keying isolates a stuck primary
without affecting the failover path because the partner address has its own breaker.
- **Files**: `AbCipDriver.cs:ResolveHost` consults `DeviceState.ActiveAddress`, plus a
small change to per-tag runtime caching so handles are keyed on the active address —
failover invalidates the handle cache and re-creates against the new gateway.
- **Test approach**: unit test that toggling the role flag flips `ResolveHost` output;
integration test deferred per PR 5.1.
- **Effort**: M
- **Dependencies**: PR 5.1.
- **Docs / fixture / e2e**: appends a "Failover behaviour" section to
`docs/drivers/AbCip-HSBY.md` documenting handle-cache invalidation + bulkhead key
semantics; appends a "Failure-mode walkthrough" to the same doc covering
primary-stuck / secondary-stuck / both-stuck cases; reuses the paired-fixture from
PR 5.1; adds `tests/.../IntegrationTests/AbCipHsbyFailoverTests.cs` driving the
role-flip via the `hsby-mux` sidecar and asserting reads route to the new active
partner; ships the new `scripts/e2e/test-abcip-hsby.ps1` (paired-fixture variant of
the standard e2e — flips the role mid-stream and asserts subscribe stream survives).
## Per-PR detail
The summary above already includes each PR's title, motivation (linked to the
featuregaps.md table row), files, test plan, and effort. To keep this section from
duplicating, here are the cross-cutting design notes and risks per phase rather than per PR.
### Phase 1 risks
- **Int64 surface change** (PR 1.1) ripples through the address-space builder + the OPC UA
variant emit. Confirm `Core.Abstractions.DriverDataType` already has `Int64`; if not, this
PR pulls in a Core change other drivers will share (Modbus has the same TODO).
- **STRINGnn variant addressing** (PR 1.2) is the smallest data-correctness PR but has the
highest unknown — libplctag's C# wrapper may flatten all string variants to its built-in
`GetString(0)` helper. If true, PR 1.2 must add a raw-buffer decode path and is then
upgraded from M to L.
- **Array-slice planner** (PR 1.3) introduces a third planner alongside the UDT planner +
the future write planner (1.4). Build them on a shared `IAbCipReadPlanner` seam so
Phase 3's strategy selector has one slot to pivot on, not three.
- **Multi-write packing** (PR 1.4) hinges on libplctag exposing CIP Multi-Service Packet
construction. If it does not, the work-around is a raw-CIP `@raw` send, which is a
bigger lift and may push 1.4 to an L-plus that drags into Phase 3.
### Phase 2 risks
- **L5K text format** has documented edge cases (escape sequences in DESCRIPTION strings,
alias resolution, nested DATATYPE blocks). Lean on Rockwell's published L5K BNF and treat
unknown sections as warnings, not failures.
- **L5X namespace handling** — Studio 5000 v32+ adds optional XML namespaces. Use
XPath with prefix-agnostic queries to avoid version-pinning the parser.
- **CSV column drift** — Kepware's column order has shifted over major versions. Implement
the importer to read by header name, not column index.
### Phase 3 risks
- **Logical addressing bootstrap cost** (PR 3.2) — symbol-table walk on first read can
stall the first poll batch. Cache the instance-ID map per `(device, last symbol-table
hash)` and persist it across `ReinitializeAsync` if feasible.
- **MultiPacket vs WholeUdt heuristic** (PR 3.3) — the threshold (e.g. "switch to
MultiPacket when fewer than 30% of UDT members are subscribed") needs benchmarking on
real rigs. Ship an explicit per-device override + pick a conservative default.
- **Connection Size on legacy firmware** (PR 3.1) — v19-and-earlier ControlLogix firmware
rejects Large Forward Open silently. Document the symptom in `docs/Driver.AbCip.md` and
emit a warning when ConnectionSize > 511 against a family profile that is
ControlLogix-typed but probed-as-v19.
### Phase 4 risks
- **Per-tag scan rate** (PR 4.1) interacts with the OPC UA subscription's
publishing-interval contract. Document that the per-tag override is a *driver-side*
publish bucket that fires `OnDataChange` events at the per-tag rate; the OPC UA layer
still aggregates them on its own publishing-interval and the client may see them at the
larger of the two intervals. This matches Kepware's "scan classes" semantics.
- **Write deadband** (PR 4.2) on UDT-fanned-out members must use the member-level cache, not
the parent UDT's cache.
### Phase 5 risks
- **HSBY role tag name** (PR 5.1) varies by firmware version; without a real HSBY pair on
the dev box the integration coverage is deferred to a customer-site smoke test. Consider
parking PR 5.1+5.2 behind a feature flag (`Redundancy.Hsby.Enabled`) and shipping unit
coverage only.
- **Bulkhead key** assumed to be `(driver, hostName)`; once `ResolveHost` returns the active
partner address that key is correct by construction, but verify Polly's per-key state is
invalidated cleanly when the active address changes mid-call.
## Documentation, fixture, and e2e impact
Cross-cutting roll-up of the per-PR `Docs / fixture / e2e` lines above. Read this before
starting any phase to plan doc + fixture + e2e work in parallel with the code change.
### New documents
- `docs/drivers/AbCip-TagImport.md` (Phase 2, lands with PR 2.1; extended by PR 2.2,
PR 2.3, PR 2.4, PR 2.6) — L5K / L5X / CSV / AOI tag-import reference.
- `docs/drivers/AbCip-Performance.md` (Phase 3, lands with PR 3.1; extended by PR 3.2,
PR 3.3) — Connection Size, Addressing Mode, Read Strategy.
- `docs/drivers/AbCip-Operability.md` (Phase 4, lands with PR 4.1; extended by PR 4.2,
PR 4.3, PR 4.4) — per-tag scan rate, write deadband, system tags.
- `docs/drivers/AbCip-HSBY.md` (Phase 5, lands with PR 5.1; extended by PR 5.2) —
paired-IP redundancy, role-tag matrix, failover semantics.
### Documents with appended sections
- `docs/Driver.AbCip.Cli.md` — gains type-table rows (PR 1.1), `--string-size` flag
(PR 1.2), slice syntax (PR 1.3), multi-write subsection (PR 1.4), `tag-import` /
`tag-export` commands (PR 2.1, PR 2.2, PR 2.4), `rebrowse` command (PR 2.5),
Connection Size note (PR 3.1), `--addressing-mode` flag (PR 3.2), system-tag
read example (PR 4.3), `_RefreshTagDb` recipe (PR 4.4), `--partner` flag plus
`hsby-status` command (PR 5.1).
- `docs/drivers/AbServer-Test-Fixture.md` — coverage map updated by every PR that
changes what ab_server actually exercises (1.1, 1.2, 1.3, 1.4, 2.6, 3.1, 3.2,
3.3, 4.2, 4.3, 5.1).
### Fixture / simulator scaffolding
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml` —
ControlLogix profile gains seeded `TestLINT`, `TestSTRING80`, extra DINT tags for
multi-write (PRs 1.1, 1.2, 1.4); a new paired-fixture mode with `hsby-mux` sidecar
for HSBY (PR 5.1).
- `tests/.../AbCip.IntegrationTests/AbServerProfile.cs` — the `KnownProfiles`
records get extended Notes lines for each new seeded tag class.
- `tests/.../AbCip.Tests/Import/Fixtures/` — new directory hosting sample L5K, L5X,
and CSV files (PRs 2.1, 2.2, 2.6, 2.4).
- `tests/.../AbCip.IntegrationTests/Emulate/` — new gated tests for AOI (PR 2.6) and
MultiPacket strategy (PR 3.3); reuses the existing `AB_SERVER_PROFILE=emulate`
gate.
- `tests/.../AbCip.IntegrationTests/LogixProject/README.md` — cross-link added when
PR 2.2 lands so the on-site Studio 5000 export doubles as a parser fixture.
### Integration / e2e scripts
- `scripts/e2e/test-abcip.ps1` — gains assertions for: LInt loopback (1.1),
STRING round-trip (1.2), array-slice read (1.3), recipe multi-write (1.4),
tag-import diff (2.1, 2.2, 2.4), Description survival (2.3), rebrowse-after-reseed
(2.5), Symbolic-vs-Logical sanity (3.2), per-tag scan-rate divergence (4.1),
write-coalesce (4.2), `_System` browse-and-read (4.3), `_RefreshTagDb` write-then-
verify (4.4).
- `scripts/smoke/seed-abcip-smoke.sql` — extended with `TestLINT`, `TestSTRING80`,
multi-write target tags, and a `ConnectionSize` field demo (PRs 1.1, 1.2, 1.4,
3.1).
- `scripts/e2e/test-abcip-hsby.ps1` — new paired-fixture variant of the standard
e2e, ships with PR 5.2; not chained into `scripts/e2e/test-all.ps1` until HSBY
exits feature-flag gating.
### Cross-cutting work
- The `Docs / fixture / e2e` lines deliberately reuse the existing
`Test-Probe` / `Test-DriverLoopback` / `Test-ServerBridge` / `Test-OpcUaWriteBridge` /
`Test-SubscribeSeesChange` helpers in `scripts/e2e/_common.ps1` — no new helper
functions are required for Phases 1-4. Phase 5 is the first phase that introduces a
new helper (`Test-FailoverDuringSubscribe`) in `_common.ps1`, shipped alongside
PR 5.2; if other drivers (TwinCAT, S7) later adopt a paired-fixture mode they can
reuse it.
- `tests/.../AbCip.IntegrationTests/AbServerFixture.cs` may need a small extension in
PR 5.1 to support the paired-port probe; the change is additive (probe both
`127.0.0.1:44818` and `127.0.0.1:44819`), keeping single-fixture tests working
unchanged.
## Skip-rated items (for context)
Copied from the Recommendations table at `docs/featuregaps.md`:
- **#7 Inactivity timeout / keep-alive cadence** — Rarely an issue with libplctag-managed
connections.
- **#9 "Respect tag-specified scan rate" mode** — Niche; OPC UA subscription rate already
covers it.
- **#10 Initial value cache / first-update from cache** — OPC UA subscription sampling
already handles first-update.
- **#15 UDT as first-class OPC UA structured type** — Member fan-out already works;
structured-type plumbing is heavy.
- **#17 PLC-5 / SLC bridging through CLX** — AbLegacy driver covers this protocol family.
- **#21 Unsolicited CIP MSG ingestion** — Separate driver in commercial; design-heavy;
niche.
- **#22 CIP Generic / Class 3 passthrough** — Niche custom-tooling territory.
- **#23 Per-device connection count / pooling** — libplctag manages connections;
premature.
## Open questions
1. **libplctag instance-ID API** (PR 3.2) — does the C# wrapper expose
`logical_segment` / `cip_addr` attributes directly, or do we have to drop down to
`Tag.AddAttribute` calls? Affects scope of Phase 3.
2. **libplctag CIP Multi-Service Packet** (PR 1.4) — is there a wrapper-level multi-write
helper, or must we go through the `@raw` pseudo-tag? Affects scope of Phase 1.
3. **`DriverDataType.Int64` / `Int64Array`** (PR 1.1) — does Core already carry it, or is
this a shared Core change with Modbus's matching TODO?
4. **HSBY role tag** (PR 5.1) — confirm the canonical Active/Standby indicator across
ControlLogix v20 / v24 / v32+; without a known tag the role-prober is speculative.
5. **AOI InOut handling** (PR 2.6) — Kepware skips InOut parameters because they are
pointers, not values. Do we follow the same precedent or attempt to dereference at
read-time? Skip is the cheap default.
6. **L5K vs L5X coverage** — if the customer base has standardised on L5X (Studio 5000
v21+), can we ship PR 2.2 first and make PR 2.1 best-effort? Affects phasing within
Phase 2.
7. **HSBY scope for v2 vs v3** — Phase 5 carries the largest unknowns; if no continuous-
process customer demands it for the v2 release, deferring Phase 5 to v3 is reasonable.
8. **Per-tag scan rate plumbing** (PR 4.1) — does `PollGroupEngine` in Core already accept
per-reference interval overrides, or does that need a Core extension shared with the
other polling-overlay drivers (Modbus, FOCAS)?

470
docs/plans/ablegacy-plan.md Normal file
View File

@@ -0,0 +1,470 @@
# AbLegacy Driver — Implementation Plan
> Source of gap analysis: [featuregaps.md → AbLegacy](../featuregaps.md#ablegacy-allen-bradley-plc-5--slc--micrologix)
>
> Covers Build = Yes items only. Skip-rated gaps listed at bottom for traceability.
## Summary
The AbLegacy driver (PCCC over EtherNet/IP via libplctag) currently ships with parsing for the canonical SLC/PLC-5/MicroLogix file letters, four PLC-family profiles, bit-within-N-word RMW writes, a probe loop, and a flat static-config tag list. The `featuregaps.md` Recommendations table flags 13 gaps as **Build = Yes**:
1. DH+ via 1756-DHRIO bridging (#2)
2. PD/MG/PLS/BT files (#5)
3. PLC-5 octal addressing (#7)
4. Indirect/indexed addressing (#8)
5. Array contiguous block addressing (#9)
6. ST string read/write production verification (#10)
7. Sub-element bit semantics (`.DN` as Bit) (#11)
8. Auto-demote on comm failure (#13)
9. RSLogix 500/5 symbol import (#15)
10. Per-tag deadband / change filter (#18)
11. Diagnostic counters as tags (#20)
12. Per-device timeout / retry overrides (#21)
13. MicroLogix function-file naming (RTC/HSC/DLS) (#23)
The plan splits these across **5 phases / 13 PRs** (one PR per gap, with a couple of small ones bundled). Phases are ordered by coupling — addressing correctness first because everything downstream depends on the parser, then file/type coverage, then performance, then workflow tooling, then resilience. Each PR is sized to fit comfortably under the project's per-PR review budget (most S/M; only the RSLogix import is L).
## Phased delivery
| Phase | Theme | PRs | Gaps |
|-------|-------|-----|------|
| 1 | Addressing correctness | 4 | #7 octal, #8 indirect, #11 sub-element bits, #23 ML function files |
| 2 | File / type coverage | 2 | #5 PD/MG/PLS/BT, #10 ST verification |
| 3 | Performance | 2 | #9 array block, #18 per-tag deadband |
| 4 | Workflow | 3 | #15 RSLogix import, #21 per-device timeouts, #20 diagnostic counters |
| 5 | Resilience | 2 | #13 auto-demote, #2 DH+ bridging |
Phase 1 lands first because Phase 2 (PD/MG/PLS/BT) and Phase 3 (array reads) both extend the parser shipped in Phase 1. Phase 5 (auto-demote) reads diagnostic counters from Phase 4 #20, so 4 precedes 5.
---
## Per-PR detail
### Phase 1 — Addressing correctness
#### PR 1 — PLC-5 octal I/O addressing (#7)
**Scope**: PLC-5 documentation and RSLogix 5 use octal for `I:` / `O:` word and bit indices (`I:001/17` is rack 0 group 0 word 1, bit 17₈ = bit 15₁₀). Today `AbLegacyAddress.TryParse` does `int.TryParse` on the word number and bit index, silently accepting decimal. For `PlcFamily=Plc5` (and only that family) `I` / `O` files must parse as octal.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs` — add `TryParse(string, AbLegacyPlcFamily)` overload; existing `TryParse(string)` keeps decimal semantics (back-compat for non-PLC-5 callers and pure shape validation).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs``EnsureTagRuntimeAsync` and the bit-RMW path call the family-aware overload using `device.Options.PlcFamily`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs` — add `OctalIoAddressing` flag (true for `Plc5` only).
**Test plan**:
- Unit (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs`): `I:001/17` parses to word=1, bit=15 under PLC-5; same string parses to bit=17 under SLC500. `O:7/10` (decimal under SLC500 = bit 10; octal under PLC-5 = bit 8).
- Round-trip: `ToLibplctagName()` must emit the format libplctag expects (verify libplctag's PLC-5 PCCC layer accepts octal-formatted I/O addresses, or whether we must convert decimal→octal-text before forwarding).
**Docs / fixture / e2e**:
- Update `docs/Driver.AbLegacy.Cli.md` — extend the "PCCC address primer" with an `I:` / `O:` row noting PLC-5 octal vs SLC500 decimal semantics; worked example showing `I:001/17` resolved differently per family.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — note octal-vs-decimal addressing as a covered family-aware parser dimension under the unit-coverage list.
- Fixture: extend `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` `plc5` profile to seed an `I:001` (or equivalent module-image word) tag if `ab_server --plc=PLC/5` accepts it; otherwise document the gap in `Docker/README.md`.
- E2E: add `--plc-type Plc5 -a "I:001/17"` octal-bit assertion to `scripts/e2e/test-ablegacy.ps1` (gated on the `plc5` compose profile being up); no change to `scripts/smoke/seed-ablegacy-smoke.sql` required (existing `N7:5` tag continues to cover the SLC500 path).
**Effort**: S
**Dependencies**: none
---
#### PR 2 — MicroLogix function-file letters (RTC / HSC / DLS / MMI / PTO / PWM / STI / EII / IOS / BHI) (#23)
**Scope**: MicroLogix 1100/1400 expose proprietary function files that don't share file letters with SLC. Today `IsKnownFileLetter` (`AbLegacyAddress.cs:97-101`) only allows the SLC/PLC-5 set, so any tag like `RTC:0.HR` is rejected at parse time even though libplctag's `micrologix` PlcType supports them.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs` — extend `IsKnownFileLetter` to recognise multi-letter function-file types (`RTC`, `HSC`, `DLS`, `MMI`, `PTO`, `PWM`, `STI`, `EII`, `IOS`, `BHI`). Permit only when family is `MicroLogix`. The letter-scan loop already accepts any contiguous letters (`AbLegacyAddress.cs:80-82`).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs` — define a sub-element catalogue per function-file (RTC has YR/MON/DAY/HR/MIN/SEC/DOW; HSC has ACC/HIP/LOP/OFS/etc.). Map each sub-element to the right `DriverDataType`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs``SupportsFunctionFiles` flag.
**Test plan**:
- Unit: `RTC:0.HR` parses with `FileLetter="RTC"`, `WordNumber=0`, `SubElement="HR"`. `HSC:0.ACC` parses. Same strings under PlcFamily=Slc500 must reject (ML1100 file types not present on SLC).
- Integration (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests`): only if a MicroLogix simulator profile exists; flag as TODO otherwise — verify libplctag `micrologix` PlcType accepts these tag names.
**Docs / fixture / e2e**:
- New doc `docs/drivers/AbLegacy-MicroLogix-FunctionFiles.md` — catalogue of supported function files (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI), per-family availability matrix (ML1100 vs ML1400 vs ML1500), sub-element-to-DriverDataType table.
- Update `docs/Driver.AbLegacy.Cli.md` — add a "MicroLogix function files" row to the PCCC address primer with `RTC:0.HR` / `HSC:0.ACC` examples and a CLI worked example.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — record fixture coverage status for function files and link to the `micrologix` profile gap (only if `ab_server --plc=Micrologix` rejects function-file addresses, document the unit-only fallback).
- Fixture: extend `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` `micrologix` profile with `--tag=RTC0[1]` / `--tag=HSC0[1]` if accepted by `ab_server`, else mark as hardware-gated in `Docker/README.md`.
- E2E: add a parametric `-PlcType MicroLogix -Address RTC:0.HR` invocation to `scripts/e2e/test-ablegacy.ps1` (skip-when-fixture-gap, mirroring the existing `BadCommunicationError` gate); no `seed-ablegacy-smoke.sql` change unless the fixture supports function-file tags.
**Effort**: M
**Dependencies**: PR 1 (parser overload signature settled)
---
#### PR 3 — Sub-element bit semantics (`.DN`, `.EN`, `.TT`, `.CU`, `.CD`, `.OV`, `.UN`, `.ER`) (#11)
**Scope**: Today `T4:0.DN` parses fine but the `TimerElement`/`CounterElement`/`ControlElement` types collapse to `Int32` (`AbLegacyDataType.cs:41-44`). HMIs expect `.DN` / `.EN` / `.TT` / `.CU` / `.CD` / `.OV` / `.UN` / `.ER` to surface as `Boolean`. The fix is to detect the sub-element at tag-runtime build time and override the driver-surface type.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs` — new helper `SubElementBitNames` (HashSet of bit-typed sub-elements per parent type — Timer: EN/TT/DN; Counter: CU/CD/DN/OV/UN; Control: EN/EU/DN/EM/ER/UL/IN/FD). New `EffectiveDriverDataType(AbLegacyDataType, string? subElement)` returning `Boolean` for bit-typed sub-elements, otherwise the existing mapping.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs``DiscoverAsync` uses `EffectiveDriverDataType(def.DataType, parsed.SubElement)`; `ReadAsync` decodes the parent word and masks the bit instead of returning the whole word as Int32.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs` — verify libplctag exposes `.DN` etc. as a single bit when read with `GetBit` against the sub-element address. If not, fall back to read-the-word + mask.
**Test plan**:
- Unit (`AbLegacyDriverTests` + new `AbLegacyDataTypeTests`): `T4:0.DN` discovers as Boolean; `T4:0.ACC` discovers as Int32; counter `.OV` is Boolean; control `.LEN` is Int32.
- Bit-write semantics: writing Boolean `true` to `T4:0.DN` should be rejected with `BadNotWritable` (timer status bits are PLC-set; verify by integration smoke test against the AbLegacy simulator).
**Docs / fixture / e2e**:
- Update `docs/Driver.AbLegacy.Cli.md` — extend the Timer/Counter/Control rows in the address primer with a "bit sub-elements surface as Boolean" note and a `--type Bool -a T4:0.DN` CLI example.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — note `AbLegacyDataTypeTests` as a new unit-coverage class under "What it actually covers".
- Fixture: no compose change required (T4/C5/R6 already seeded by `ab_server` defaults — verify; if not, add `--tag=T4[5]`/`--tag=C5[5]`/`--tag=R6[5]` to the `slc500` profile in `Docker/docker-compose.yml`).
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with a Boolean sub-element read assertion (`read --type Bool -a T4:0.DN`) once the simulator round-trip works. Update `scripts/smoke/seed-ablegacy-smoke.sql` to add a Boolean tag binding `T4:0.DN` so the server-bridge assertion exercises the new mapping.
**Effort**: M
**Dependencies**: none (independent of PR 1/2 parser changes)
---
#### PR 4 — Indirect / indexed addressing parser (`N7:[N7:0]`, `N[N7:0]:5`) (#8)
**Scope**: Recipe / batch lookup tables use `N7:[N7:0]` (read N7 word indexed by the value at N7:0) or `N[N7:0]:5`. Today `AbLegacyAddress.TryParse` rejects both because it requires literal integer word and file numbers.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs` — record gains nullable `IndirectFileSource` and `IndirectWordSource` (each itself an `AbLegacyAddress`). Parser handles `[<inner>]` segments at file-number or word-number positions. Recursion depth capped at 1 (libplctag accepts only one level of indirection per address — verify against libplctag PCCC docs).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs` — no change.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — pass-through; `ToLibplctagName()` re-emits the bracket form.
**Test plan**:
- Unit: `N7:[N7:0]` → outer file=N7, indirect word source = (N, 7, 0); `B3:[N7:0]/0` → bit, indirect word source = (N, 7, 0); `N[N7:0]:5` → indirect file source = (N, 7, 0), word=5; depth-2 (`N[N[N7:0]:5]:0`) must reject.
- Integration: verify libplctag's `slc500`/`plc5` PlcType accepts a `Name` of form `N7:[N7:0]` and resolves at read time. (If libplctag rejects indirect text, fall back to two-step read: resolve the inner address, then read the outer with the resolved index. Document the chosen strategy in the PR.)
**Docs / fixture / e2e**:
- New doc `docs/drivers/AbLegacy-Indirect-Addressing.md` — explain `N7:[N7:0]` and `N[N7:0]:5` syntax, the depth-1 limit, the chosen libplctag strategy (verbatim pass-through vs two-step resolve), and recipe-table use cases.
- Update `docs/Driver.AbLegacy.Cli.md` — add an indirect-addressing row to the address primer with `--address "N7:[N7:0]"` example.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — under unit coverage, list `AbLegacyAddressTests` indirect-parsing cases.
- Fixture: no `Docker/docker-compose.yml` change required (`N7[10]` already seeded; the inner index tag at `N7:0` is already addressable). Document recipe-pattern in `Docker/README.md`.
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with an indirect-address driver-loopback case (write to `N7:0` to set the index, then read `N7:[N7:0]` and assert the value matches the previously-written content of the resolved word). Skip-gate behind libplctag capability check.
**Effort**: M
**Dependencies**: PR 1 (octal resolution must apply to inner address too if the outer file is `I:`/`O:` on PLC-5)
---
### Phase 2 — File / type coverage
#### PR 5 — PD / MG / PLS / BT structure files (#5)
**Scope**: Add PD (PID), MG (Message), PLS (Programmable Limit Switch), BT (Block Transfer) file types to the parser and the data-type catalogue. PD has SP/PV/CV/Error/Bias plus 25+ sub-elements; MG has Error/Length/Position/etc.; PLS has LEN/POS; BT is similar to MG.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs` — extend `IsKnownFileLetter` with `PD`, `MG`, `PLS`, `BT`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs` — new enum members `PidElement`, `MessageElement`, `PlsElement`, `BlockTransferElement`. Sub-element catalogue per type — many PD sub-elements are Float32 (`SP`, `PV`, `CV`, `KP`, `KI`, `KD`), some are Boolean (`EN`, `DN`, `MO`, `PE`), some Int16 (`SPS`, `MAXS`, `MINS`).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs` — verify libplctag PCCC supports addressing PD/MG/PLS/BT sub-elements by name; if not, the driver reads the parent struct as a byte block and offsets internally (libplctag docs to consult).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs``SupportsPidFile` etc. flags (PLC-5 supports PD/BT; SLC supports PD; ML1100/1400 generally do not — verify per family docs).
**Test plan**:
- Unit: `PD9:0.SP` → Float32; `PD9:0.EN` → Boolean; `MG10:0.LEN` → Int32; reject `PD9:0` (no sub-element on a struct file).
- Integration: smoke test against a simulator with PD file configured (verify pylogix/pycomm3 sim supports PD, otherwise mark as TODO and lean on unit coverage).
**Docs / fixture / e2e**:
- New doc `docs/drivers/AbLegacy-Structure-Files.md` — sub-element catalogues for PD / MG / PLS / BT, per-family availability matrix (PLC-5 vs SLC vs ML), DriverDataType per sub-element.
- Update `docs/Driver.AbLegacy.Cli.md` — add PD / MG / PLS / BT rows to the file-letter primer with `--type PidElement` etc. examples.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — list new structure-file file letters under unit coverage and note any fixture limitations (pd/mg likely not supported by `ab_server`).
- Fixture: extend `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` `slc500` and `plc5` profiles with `--tag=PD9[2]` / `--tag=MG10[2]` if `ab_server` accepts; otherwise document gap in `Docker/README.md` and rely on unit coverage.
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with a `read --type Float -a PD9:0.SP` assertion when fixture exposes the file; add a corresponding tag row to `scripts/smoke/seed-ablegacy-smoke.sql` (skip-gated).
**Effort**: M
**Dependencies**: PR 3 (sub-element bit semantics machinery must exist first — PD `.EN` is Boolean by the same mechanism as Timer `.EN`)
---
#### PR 6 — ST string read/write production verification (#10)
**Scope**: ST is enum-listed and `LibplctagLegacyTagRuntime.DecodeValue` calls `_tag.GetString(0)`, but there's no integration coverage that ST round-trips through libplctag's 82-byte length-word format. This PR is verification + any fixes uncovered.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs` — likely no source change if libplctag's `GetString`/`SetString` already handles the length-word convention; if not, add `GetByteArrayBuffer` + manual length-word decode.
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` — add `ST_RoundTrip_*` tests against the simulator: write 82-char string, write 0-char, write 41-char, write embedded null/non-ASCII; round-trip each through ReadAsync.
- New `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyStringEncodingTests.cs` — unit-level decode of a known length-word + payload byte buffer (mock `IAbLegacyTagRuntime` returning fixed bytes).
**Test plan**:
- Integration: 4 round-trip cases above; covers PlcFamily=Slc500 and PlcFamily=Plc5 (libplctag may handle the length word differently between the two PCCC layers — verify).
- Quality: unit test that `BadOutOfRange` surfaces when caller writes a 100-char string to an 82-byte ST.
**Docs / fixture / e2e**:
- Update `docs/Driver.AbLegacy.Cli.md` — expand the `ST` row in the address primer with the 82-byte limit, length-word convention, and a `write --type String --value "Hello"` worked example.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — list the new `AbLegacyStringEncodingTests` unit class and the four `ST_RoundTrip_*` integration cases under coverage.
- Fixture: extend `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` `slc500` and `plc5` profiles with `--tag=ST20[5]` so the round-trip tests have a real address to write against; document any `ab_server` ST gaps in `Docker/README.md`.
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with a String round-trip case (`-a "ST20:0" --type String`) and a `String` tag row in `scripts/smoke/seed-ablegacy-smoke.sql` so the bridge assertion exercises ST.
**Effort**: S (mostly tests; small encoding fix if any)
**Dependencies**: none
---
### Phase 3 — Performance
#### PR 7 — Array contiguous block addressing (`N7:0,10` or `N7:0[10]`) (#9)
**Scope**: One PCCC frame can pull up to ~120 words. Today every tag is a separate libplctag instance and a separate request. The fix exposes array tags as a single tag with `IsArray=true` + `ArrayDim`, backed by a libplctag tag with `elem_count=N`.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs` — record gains `ArrayCount` (nullable). Parser accepts `,N` suffix (Rockwell convention) and `[N]` suffix (libplctag convention) on the word number. Reject combination with sub-element or bit index.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs``AbLegacyTagDefinition` gains optional `ArrayLength` (overrides parsed value; convenient when address is parameterised).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs``AbLegacyTagCreateParams` gains `ElementCount` (default 1).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs` — pass `ElementCount` to libplctag `Tag.ElementCount` (verify libplctag supports element counts on PCCC PlcTypes — it does for ab_eip CIP tags but PCCC may behave differently).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs``DiscoverAsync` emits `IsArray=true`, `ArrayDim=[N]`; `ReadAsync` decodes via per-index `_tag.GetInt16(i*2)` etc.
**Test plan**:
- Unit: `N7:0,10` parses ArrayCount=10; `N7:0[10]` same; `N7:0,10/3` rejects (array+bit); `T4:0,5.ACC` rejects (array+sub-element).
- Integration: read `N7:0,10` returns 10 elements in one frame; latency measurement vs 10 individual tags should be ≥ 5x faster (target).
**Docs / fixture / e2e**:
- Update `docs/Driver.AbLegacy.Cli.md` — add an "Array reads" section explaining `N7:0,10` vs `N7:0[10]` syntax and the per-PCCC-frame ~120-word ceiling, plus a `read --array-length 10 -a N7:0,10` CLI example.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — list array-block reads under unit coverage and note the latency benchmark integration test as a new perf-flagged case.
- Fixture: confirm `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` `--tag=N7[10]` / `--tag=F8[10]` already provide enough contiguous words; otherwise bump array sizes (`N7[120]` to allow max-frame tests).
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with a `read -a "N7:0,10"` array assertion (parse comma-separated CLI output); add a matching `IsArray=1` tag row in `scripts/smoke/seed-ablegacy-smoke.sql` to exercise the address-space side.
**Effort**: M
**Dependencies**: PR 1 (octal applies to array index when the file is I/O on PLC-5)
---
#### PR 8 — Per-tag deadband / change filter (#18)
**Scope**: Today `PollGroupEngine` publishes every poll. Add absolute and percent deadband per tag — only emit `OnDataChange` when the new value differs by ≥ deadband.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs``AbLegacyTagDefinition` gains `AbsoluteDeadband` (double?), `PercentDeadband` (double?).
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — wrap the `PollGroupEngine` callback with a per-tag last-published-value cache and the deadband test. Booleans bypass deadband (always change-on-edge). Strings + status changes always publish.
- Verify: `PollGroupEngine` (in `Core.Drivers`) doesn't already centralise this — if it does, this PR threads the per-tag config through the engine instead of layering on top.
**Test plan**:
- Unit (new `AbLegacyDeadbandTests`): tag with `AbsoluteDeadband=1.0` reading `[10.0, 10.5, 11.5, 11.6]` publishes only `10.0` and `11.5`. Boolean tag publishes every transition. Status code change always publishes.
- Quality: ensure last-value cache doesn't leak across `ReinitializeAsync`.
**Docs / fixture / e2e**:
- Update `docs/Driver.AbLegacy.Cli.md` — add a "Deadband" subsection under subscribe with `--deadband-absolute` / `--deadband-percent` CLI flags and example.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — list `AbLegacyDeadbandTests` under unit coverage.
- Fixture: no compose change required (per-tag deadband is a config-side concern, not a server simulator one).
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with a deadband subscribe assertion (subscribe with `--deadband-absolute 5`, write three small deltas, assert only one notification fires); add a tag row to `scripts/smoke/seed-ablegacy-smoke.sql` with `AbsoluteDeadband=5` to exercise the seed-from-config path.
**Effort**: S
**Dependencies**: none
---
### Phase 4 — Workflow
#### PR 9 — Per-device timeout / retry overrides (#21)
**Scope**: Replace single driver-wide `Timeout` with per-device override (SLC 5/01 needs ~5 s, SLC 5/05 fine at 2 s, ML1100 sometimes 3 s). Optional retry count per device.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs``AbLegacyDeviceOptions` gains optional `Timeout`, `Retries`. `AbLegacyDriverOptions.Timeout` becomes the driver-wide default.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs``EnsureTagRuntimeAsync` and `ProbeLoopAsync` use `device.Options.Timeout ?? _options.Timeout`. `ReadAsync` retry loop honours `device.Options.Retries`.
**Test plan**:
- Unit: device with `Timeout=TimeSpan.FromSeconds(5)` propagates into `AbLegacyTagCreateParams.Timeout`; absent override falls back to driver-wide.
- Integration: simulate a slow device (1 s artificial delay) — driver-wide 2 s passes; reducing per-device to 500 ms surfaces `BadCommunicationError` on the slow device while the fast device keeps reading.
**Docs / fixture / e2e**:
- Update `docs/Driver.AbLegacy.Cli.md` — document per-device `--timeout-ms` / `--retries` precedence vs driver-wide defaults; add a tuning cheat-sheet for SLC 5/01 vs 5/05 vs ML1100.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — note per-device options under the AbLegacyDeviceOptions surface.
- Fixture: no compose change. Add a slow-device test harness using a `tc qdisc add dev eth0 delay 1000ms` sidecar (or a Linux `iptables -j DELAY` shim) — document in `Docker/README.md` as an optional perf-tuning fixture.
- E2E: no `test-ablegacy.ps1` change needed (per-device timeout is integration-test territory). Add a `Timeout=PT500MS` device-level row to `scripts/smoke/seed-ablegacy-smoke.sql` so the seed path exercises the new column.
**Effort**: S
**Dependencies**: none
---
#### PR 10 — Diagnostic counters as tags (#20)
**Scope**: Per-device diagnostic counters (request count, response count, retry count, last-error code, comm-failures) surface as auto-generated tags under `AbLegacy/<host>/_Diagnostics/*` so HMIs can bind directly. Mirrors what other drivers expose.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs``DeviceState` gains `Counters` (record of int64s). `ReadAsync`, `WriteAsync`, `ProbeLoopAsync` increment counters on success/failure paths. `DiscoverAsync` emits a `_Diagnostics` folder per device with seven Variables: `RequestCount`, `ResponseCount`, `ErrorCount`, `RetryCount`, `LastErrorCode`, `LastErrorMessage`, `CommFailures`.
- New `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs` — generates the 7 well-known tag names; reading them returns counter snapshots from `DeviceState.Counters`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` `ReadAsync` short-circuits diagnostic tag references before dispatching to libplctag.
**Test plan**:
- Unit (new `AbLegacyDiagnosticsTests`): force 5 reads (3 success, 2 fail) → `RequestCount=5`, `ErrorCount=2`. `LastErrorCode` reflects the last libplctag status. Counters reset on `ReinitializeAsync`.
- Quality: verify the 7 well-known names don't collide with user-config tag names (reject overlap at `InitializeAsync`).
**Docs / fixture / e2e**:
- New doc `docs/drivers/AbLegacy-Diagnostics.md` — the seven well-known counter tag names, their semantics, namespace convention (`_Diagnostics` folder per device), reset behaviour on `ReinitializeAsync`, and HMI binding examples.
- Update `docs/Driver.AbLegacy.Cli.md` — note that diagnostic tags surface alongside user-config tags and can be `read --address _Diagnostics/RequestCount` (or whatever the canonical CLI shape ends up being).
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — list `AbLegacyDiagnosticsTests` and call out the collision-rejection contract.
- Fixture: no compose change.
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with a "after N reads, RequestCount==N" assertion against the diagnostic NodeId published by the OPC UA server-bridge step; add a `_Diagnostics/RequestCount` Tag row to `scripts/smoke/seed-ablegacy-smoke.sql` if the addr-space team requires explicit registration.
**Effort**: M
**Dependencies**: none
---
#### PR 11 — RSLogix 500 / PLC-5 symbol & data-table import (#15)
**Scope**: Import RSLogix exports (`.RSS` Slc500, `.RSP` Plc5, `.SLC` text export) to seed `AbLegacyTagDefinition` entries. The binary `.RSS`/`.RSP` formats are proprietary and largely undocumented; the practical strategy is to support the `.SLC` / `.CSV` text exports that RSLogix can produce ("save as text" / "Database Export"). Verify whether libplctag or a sister project ships an `.RSS` parser — if not, scope to text exports only and document the binary case as a future enhancement.
**Files**:
- New `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Import/RsLogixSymbolImport.cs` — parses RSLogix text export (CSV: `Symbol,Address,Description,DataType,Scope`).
- New `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Import/IRsLogixImporter.cs` — abstraction for future binary support.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` — extension method `AddRsLogixImport(string path, string deviceHostAddress)` materialises `AbLegacyTagDefinition` entries from the file at startup-time.
- New CLI command in `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/` (mirrors AbCip CLI patterns — verify: confirm the AbLegacy CLI project layout): `import-rslogix --file foo.csv --device ab://... --emit appsettings-fragment`.
**Test plan**:
- Unit (new `RsLogixSymbolImportTests`): canonical CSV with one of each file letter (N/F/B/L/ST/T/C/R) generates 8 `AbLegacyTagDefinition` entries with correct `DataType`. Malformed rows skipped with logged warning. Comments and header rows skipped.
- Integration: an end-to-end test with a recorded RSLogix CSV (committed under `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/`) produces an addr-space matching a golden snapshot.
**Docs / fixture / e2e**:
- New doc `docs/drivers/AbLegacy-RSLogix-Import.md` — supported export formats (CSV / .SLC text), CSV column convention, scope handling, the `import-rslogix` CLI subcommand, and the explicit non-goal of binary `.RSS`/`.RSP` parsing for v1.
- Update `docs/Driver.AbLegacy.Cli.md` — add an `import-rslogix` subcommand row to the commands table with `--file foo.csv --device ab://... --emit appsettings-fragment` example.
- Update `docs/DriverClis.md` if it carries a per-CLI command matrix.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — list `RsLogixSymbolImportTests`, the new `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/` golden CSV, and the import-then-read integration scenario.
- Fixture: new committed CSV under `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/rslogix-canonical.csv` plus the corresponding golden snapshot. No `Docker/docker-compose.yml` change.
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with an `import-rslogix` invocation that emits an appsettings fragment, then asserts the resulting tag count matches the CSV row count. No `seed-ablegacy-smoke.sql` change (importer is offline tooling).
**Effort**: L (parser + CLI + golden-snapshot fixture)
**Dependencies**: PR 15 complete (importer must produce addresses the parser accepts)
---
### Phase 5 — Resilience
#### PR 12 — Auto-demote on comm failure (#13)
**Scope**: When a device fails N consecutive reads/probes, mark it Demoted and skip its tags for `DemoteFor` seconds — so one slow PLC doesn't starve fast PLCs sharing the same driver/poll cadence.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs` — new `AbLegacyDemoteOptions { FailureThreshold=3, DemoteFor=TimeSpan.FromSeconds(30), Enabled=true }` on `AbLegacyDeviceOptions`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs``DeviceState` gains `ConsecutiveFailures`, `DemotedUntilUtc`. `ReadAsync` short-circuits demoted devices with `BadCommunicationError` until `DemotedUntilUtc`. `ProbeLoopAsync` clears demote on first success. New `HostState.Demoted` enum value (verify `HostState` is in `Core.Abstractions` and adding a member is non-breaking).
- Diagnostic tags from PR 10 gain `DemoteCount` and `LastDemotedUtc`.
**Test plan**:
- Unit (new `AbLegacyAutoDemoteTests`): force 3 consecutive failures → device transitions to `Demoted`; reads while demoted return `BadCommunicationError` without invoking libplctag (verify via test fake counting `ReadAsync` calls). After `DemoteFor` expires, the next read attempt goes through.
- Integration: two devices on the same driver, one with a fault — fault doesn't slow down the healthy one.
**Docs / fixture / e2e**:
- New doc `docs/drivers/AbLegacy-AutoDemote.md` (or a section appended to `AbLegacy-Diagnostics.md` from PR 10) — failure-threshold + demote-window semantics, interaction with the probe loop, the `HostState.Demoted` enum value, recovery path.
- Update `docs/Driver.AbLegacy.Cli.md` — add `--demote-failure-threshold` / `--demote-for` per-device flags and document how `probe` reflects the Demoted state.
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — list `AbLegacyAutoDemoteTests` and the two-device fault-isolation integration case.
- Fixture: extend `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml` with a second `slc500-faulty` service that listens on `:44819` but rejects every read (or doesn't bind, simulating ECONNREFUSED). The driver test then targets both `:44818` (healthy) and `:44819` (faulty) to exercise demotion.
- E2E: extend `scripts/e2e/test-ablegacy.ps1` with a "kill simulator, observe demotion in `_Diagnostics/DemoteCount`" assertion (gated on PR 10's diagnostic tags being present). Add a `DemoteFor=PT30S` device row to `scripts/smoke/seed-ablegacy-smoke.sql`.
**Effort**: M
**Dependencies**: PR 10 (diagnostic counters)
---
#### PR 13 — DH+ via 1756-DHRIO bridging (#2)
**Scope**: Allow addressing a PLC-5 sitting on a DH+ link reached through a ControlLogix chassis with a 1756-DHRIO module. The CIP path syntax is `1,<slot>,2,<dh+_station_octal>` — already accepted as a string by `AbLegacyHostAddress`, but we should validate and document it, and verify libplctag's `plc5` PlcType resolves DH+ stations correctly through the DHRIO port.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs` — add validation for the DH+ path form `1,<slot>,2,<station>` where station is 0..77 octal. Surface the parsed components (`BackplaneSlot`, `DhPlusPort`, `DhPlusStation`) for diagnostics.
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs` — note that DH+ bridging is a `Plc5`-only path (DHRIO doesn't bridge to SLC/ML).
- `docs/Driver.AbLegacy.Cli.md` — add a worked example of DHRIO routing.
**Test plan**:
- Unit (`AbLegacyHostAndStatusTests`): `ab://10.0.0.1/1,3,2,07` parses with slot=3, station=7₈=7. `ab://10.0.0.1/1,3,2,77` parses station=77₈=63. `ab://10.0.0.1/1,3,2,80` rejects (octal range).
- Integration: requires a real DHRIO + PLC-5 — flag as hardware-gated; cover with unit-only for now and document the manual smoke procedure (`docs/Driver.AbLegacy.Cli.md`).
**Docs / fixture / e2e**:
- New doc `docs/drivers/AbLegacy-DH-Bridging.md` — the `1,<slot>,2,<station_octal>` CIP path syntax, DHRIO module wiring overview, octal-station-number reference (00..77 octal = 0..63), restriction to PLC-5 family, and the manual smoke procedure since DHRIO can't be simulated.
- Update `docs/Driver.AbLegacy.Cli.md` — extend the family/cip-path cheat sheet with a "PLC-5 via DHRIO" row showing `ab://logix-host/1,3,2,07` and a worked CLI example. (Plan already calls this out at line 279 — keep it, but link to the new dedicated doc.)
- Update `docs/drivers/AbLegacy-Test-Fixture.md` — note that DH+ bridging is unit-only (no fixture support possible) and reference the manual hardware smoke procedure.
- Fixture: no `Docker/docker-compose.yml` change is feasible (DHRIO is hardware-only).
- E2E: no new automated `test-ablegacy.ps1` case (would require real DHRIO). Add a `-DhPlusStation 7` parameter form documented in the script comment header for hardware-gated runs only. No `seed-ablegacy-smoke.sql` change.
**Effort**: S
**Dependencies**: PR 1 (octal parsing utility) — share the octal-int helper between PR 1 and PR 13.
---
## Documentation, fixture, and e2e impact
Consolidated view of every doc, fixture, and e2e/smoke artefact this plan touches, so reviewers and PR authors can size the non-code surface area at a glance.
### New docs (created by this plan)
| Doc | Created by | Purpose |
|-----|-----------|---------|
| `docs/drivers/AbLegacy-MicroLogix-FunctionFiles.md` | PR 2 | Function-file catalogue (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI), per-family availability, sub-element types |
| `docs/drivers/AbLegacy-Indirect-Addressing.md` | PR 4 | `N7:[N7:0]` and `N[N7:0]:5` syntax, depth-1 limit, libplctag strategy |
| `docs/drivers/AbLegacy-Structure-Files.md` | PR 5 | PD / MG / PLS / BT sub-element catalogues + per-family availability matrix |
| `docs/drivers/AbLegacy-Diagnostics.md` | PR 10 | Seven well-known counter tag names, namespace convention, reset semantics |
| `docs/drivers/AbLegacy-RSLogix-Import.md` | PR 11 | CSV / `.SLC` text-export schema, `import-rslogix` CLI, binary-format non-goals |
| `docs/drivers/AbLegacy-AutoDemote.md` (or PR 10 doc extension) | PR 12 | Demote thresholds, recovery, `HostState.Demoted` semantics |
| `docs/drivers/AbLegacy-DH-Bridging.md` | PR 13 | `1,<slot>,2,<station_octal>` CIP path, DHRIO wiring, manual smoke procedure |
### Updated docs (extended by this plan)
- `docs/Driver.AbLegacy.Cli.md` — extended by **every** PR (octal I/O, function files, sub-element bits, indirect, structure files, ST round-trip, array reads, deadband flags, per-device timeouts, diagnostic tags, RSLogix import subcommand, demote flags, DHRIO cheat-sheet row).
- `docs/drivers/AbLegacy-Test-Fixture.md` — extended by **every** PR with new unit test classes, integration cases, and fixture limitations.
- `docs/DriverClis.md` — touched by PR 11 (new `import-rslogix` subcommand row).
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md` — touched by PRs 1, 2, 4, 5, 9, 12 (fixture limitations, optional perf-tuning sidecars, faulty-device service, recipe-pattern note).
### Fixture / scaffolding work
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml`:
- PR 1: extend `plc5` profile with `I:001`-style tags (if `ab_server` accepts).
- PR 2: extend `micrologix` profile with `RTC0[1]`/`HSC0[1]` (if accepted).
- PR 3: extend `slc500` profile with `T4[5]`/`C5[5]`/`R6[5]` if not already seeded by `ab_server` defaults.
- PR 5: extend `slc500` and `plc5` profiles with `PD9[2]`/`MG10[2]` (if accepted).
- PR 6: extend `slc500` and `plc5` profiles with `ST20[5]`.
- PR 7: bump array sizes (`N7[120]`) for max-frame array-read tests.
- PR 12: add a second `slc500-faulty` service for demotion/fault-isolation tests.
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/`:
- PR 11: new `rslogix-canonical.csv` + golden snapshot for the symbol-import integration test.
### E2E / smoke scripts
- `scripts/e2e/test-ablegacy.ps1`:
- PR 1: octal-bit `Plc5` assertion.
- PR 2: `MicroLogix RTC:0.HR` parametric.
- PR 3: Boolean sub-element read (`T4:0.DN`).
- PR 4: indirect-address loopback.
- PR 5: `PD9:0.SP` Float read (skip-gated).
- PR 6: ST round-trip.
- PR 7: array-read `N7:0,10`.
- PR 8: deadband subscribe assertion.
- PR 10: `_Diagnostics/RequestCount` assertion via OPC UA bridge.
- PR 11: `import-rslogix` invocation + tag-count assertion.
- PR 12: kill-simulator-and-observe-demote assertion.
- PR 13: parameter-only header note for hardware-gated DHRIO runs.
- `scripts/smoke/seed-ablegacy-smoke.sql`:
- PR 3: `T4:0.DN` Boolean tag row.
- PR 5: `PD9:0.SP` PidElement tag row (skip-gated).
- PR 6: `ST20:0` String tag row.
- PR 7: `N7:0,10` array tag row (`IsArray=1`).
- PR 8: tag row with `AbsoluteDeadband=5`.
- PR 9: device row with `Timeout=PT500MS`.
- PR 10: `_Diagnostics/RequestCount` tag row (if explicit registration required).
- PR 12: device row with `DemoteFor=PT30S`.
---
## Skip-rated items (for context)
For traceability, the gaps the recommendations table flagged **No**:
| # | Gap | Skip rationale |
|---|-----|----------------|
| 1 | Serial DF1 transports (full-duplex, half-duplex, KF2/KF3) | libplctag has no serial path; declining install base |
| 3 | DH-485 routing (1761/1747-AIC) | Very legacy; rare in greenfield |
| 4 | M0 / M1 module file access | Niche RIO modules; declining |
| 6 | D (BCD) and Long-BCD types | Very legacy data convention |
| 12 | Block read-size negotiation per family | libplctag handles chunking implicitly |
| 14 | Channel-shared comm serialisation | Only matters for serial / DH+ transport (not built) |
| 16 | Online controller browse / data-table discovery | PCCC dir frame limited; libplctag support unclear |
| 17 | DF1 BCC vs CRC-16 selection | Predicated on DF1 transport (gap #1) |
| 19 | PLC-5 typed-read selection / Force Logical | libplctag defaults are sound; niche tuning |
| 22 | Write completion semantics options | Niche tuning; current write-through is safe default |
These remain documented in `featuregaps.md` and can be reopened if customer feedback warrants.
---
## Open questions
1. **libplctag PCCC capability verification** — several PRs (especially 2, 4, 5, 7) hinge on what libplctag's `slc500` / `micrologix` / `plc5` / `logixpccc` PlcTypes actually accept in the `Name` attribute. Before scheduling Phase 2 we should run a one-day spike with the AbLegacy simulator to confirm:
- Does libplctag accept indirect addresses (`N7:[N7:0]`) verbatim, or do we need to resolve in two steps?
- Does it accept array notation (`N7:0,10` vs `N7:0[10]`) for PCCC PlcTypes?
- Does it expose PD/MG/PLS/BT sub-elements by name, or do we read the parent struct as a byte block?
- Does it correctly handle PLC-5 octal in I:/O: addresses, or does the driver need to convert?
2. **MicroLogix simulator fidelity** — we don't currently know whether the AbLegacy integration-test fixture (`AbLegacyServerFixture`) simulates the MicroLogix function files (RTC/HSC/DLS). PR 2's integration coverage is gated on this. If not, we either extend the fixture or scope PR 2 to unit-only tests + a hardware smoke-test playbook.
3. **RSLogix import format coverage** — binary `.RSS` / `.RSP` parsing is non-trivial. PR 11 scopes to text/CSV exports. Should we instead invest in shelling out to the (free) Rockwell `RSWho` / `RSLogix Emulate` tooling for binary conversion, or accept text-only as the v1 scope and revisit?
4. **Address-space rebuild on tag-set change** — when PR 11 (RSLogix import) adds 1000+ tags, does `ReinitializeAsync` perform acceptably, or do we need an incremental discovery path? Out of scope for this plan but worth flagging.
5. **Diagnostic tag namespace collision** — PR 10 reserves `_Diagnostics` under each device folder. Confirm with the address-space team that the leading underscore is the established convention (other drivers use `_System` or `_DiagnosticTags`); align before implementation.

807
docs/plans/focas-plan.md Normal file
View File

@@ -0,0 +1,807 @@
# 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.

View File

@@ -0,0 +1,863 @@
# OpcUaClient Driver — Implementation Plan
> Source of gap analysis: [featuregaps.md → OpcUaClient](../featuregaps.md#opcuaclient-opc-ua-aggregation-client)
>
> Covers Build = Yes items only. Numbering matches the featuregaps Recommendations table.
## Summary
The OpcUaClient driver already ships 8/8 capability interfaces and a working
end-to-end Session/Subscription/MonitoredItem/HistoryRead pipeline backed by
the OPC Foundation `OPCFoundation.NetStandard.Opc.Ua.Client` SDK. Most of the
14 Build = Yes gaps are operability or curation knobs — config surface +
plumbing into existing SDK calls — rather than new protocol implementation.
A small number need genuinely new SDK plumbing (Reverse Connect,
ModelChangeEvent subscribe) and one (`ReadEventsAsync`) needs a coordinated
cross-driver interface change.
The plan groups the work into five phases, ordered to deliver per-tag /
per-subscription operability first (highest-frequency operator pain), then
curation, then change tracking, then connectivity, then historical+HA. Each
PR sticks to one feature-gap row so reviews stay narrow.
## Phased delivery
| Phase | Theme | Gaps | PRs | Notes |
| :---: | --- | --- | :---: | --- |
| 1 | Operability knobs | #5, #6, #15, #17, #20 | 5 | Pure SDK config surface; no new wire flows |
| 2 | Discovery & curation | #2, #7, #8, #9 | 4 | Touches `ITagDiscovery` + adds method invoke |
| 3 | Change tracking | #10 | 1 | New session-level subscription on `Server` node |
| 4 | Connectivity | #1 | 1 | Reverse Connect — new listener path |
| 5 | Historical & redundancy | #12, #13, #14 | 3 | Includes the cross-driver `IHistoryProvider` change |
**Total: 14 PRs across 5 phases.** Phases 1-3 land independently against
the existing single-session model. Phase 4 ships in parallel with phases 2-3
since it doesn't touch `OpcUaClientDriver` proper. Phase 5's first PR is a
prerequisite for the `ReadEventsAsync` work in every other history-capable
driver and must coordinate with them.
## Per-PR detail
### Phase 1 — Operability knobs
#### PR-1: Per-subscription tuning (gap #6)
**Goal**: lift the hard-coded `KeepAliveCount=10`, `LifetimeCount=1000`,
`MaxNotificationsPerPublish=0`, `Priority=0`, `PublishingInterval` floor of
50 ms into `OpcUaClientDriverOptions` so high-event-rate servers can be
defended against (`MaxNotificationsPerPublish=0` is unlimited — the
documented DoS surface) and high-tag-count deployments can split by
priority.
**SDK API**:
- `Subscription.SetPublishingMode(bool, ct)` for runtime enable/disable
- `SubscriptionOptions.PublishingInterval / KeepAliveCount / LifetimeCount /
MaxNotificationsPerPublish / Priority` set at create-time
- New options class `OpcUaSubscriptionDefaults` (publish interval floor,
keep-alive count, lifetime count, max notifications, priority)
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `Subscriptions`
sub-section
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAsync` reads from
options
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAlarmsAsync` reuses
same defaults but with `Priority=1` higher than data subscriptions so
alarms aren't starved during data bursts
**Tests**: `OpcUaClientSubscribeAndProbeTests` — assert options propagate;
add a stress unit test (mocked `Subscription`) that asserts custom
`MaxNotificationsPerPublish` is forwarded so a value > 0 actually reaches
the SDK.
**Risks**: Setting `LifetimeCount` too low against a server with publish-
throttling can drop subscriptions; doc the formula (`LifetimeCount >=
3 * KeepAliveCount`).
**Docs / fixture / e2e**: new "Subscription tuning" subsection in
`docs/drivers/OpcUaClient.md` (create if missing) documenting the
`Subscriptions` options block with the `LifetimeCount >= 3 *
KeepAliveCount` formula; cross-link from the "Advanced options" section
of `docs/Client.CLI.md` so CLI users discover the knobs. Fixture: opc-plc
already publishes fast tickers (`FastUInt1` @ 100 ms) sufficient for
coverage — no fixture-side change. Integration test in
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserting
custom `KeepAliveCount` / `Priority` reach the wire (capture via
`OpcPlcFixture` keepalive count). E2E: extend
`scripts/e2e/test-opcuaclient.ps1` with a stage that sets a non-default
publish interval and confirms the local subscription honours it.
---
#### PR-2: Per-tag advanced subscription tuning incl. deadband (gap #5)
**Goal**: surface `SamplingInterval`, `QueueSize`, `DiscardOldest`,
`MonitoringMode`, and `DataChangeFilter` (DeadbandType=Absolute/Percent +
Trigger=Status/StatusValue/StatusValueTimestamp) per-tag. Deadband is the
baseline analog noise filter every commercial UA aggregator ships and the
single feature most likely to cut bandwidth on busy plants.
**SDK API**:
- `MonitoredItem.Filter = new DataChangeFilter { Trigger =
DataChangeTrigger.StatusValue, DeadbandType = (uint)DeadbandType.Absolute,
DeadbandValue = 0.5 }`
- `MonitoredItemOptions.QueueSize / DiscardOldest / SamplingInterval /
MonitoringMode`
- Per-tag override structure: extend the `SubscribeAsync` parameter shape
(or add an overload accepting a `IReadOnlyList<MonitoredTagSpec>`) — note
this requires coordinating with `ISubscribable` so the per-tag carrier
reaches the driver.
**Files**:
- `src/.../Core.Abstractions/ISubscribable.cs` — add overload
`SubscribeAsync(IReadOnlyList<MonitoredTagSpec>, ...)` keeping old API
for source compat
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — translate spec → SDK filter
**Tests**: assert `DataChangeFilter` lands on the `MonitoredItem.Filter` for
each kind of trigger; assert PercentDeadband requires server-side
EURange (server returns `BadFilterNotAllowed` if not configured) — capture
the StatusCode and surface as a usable error.
**Risks**: cross-cutting `ISubscribable` change. Mitigation: ship the
overload as additive — existing single-arg path still exists.
**Docs / fixture / e2e**: new "Per-tag deadband and monitoring filters"
section in `docs/drivers/OpcUaClient.md` (create if missing) with worked
examples of Absolute vs Percent deadband + the EURange prerequisite;
update `docs/Client.CLI.md` `subscribe` command page with the new tag-
config syntax for `--deadband` / `--queue-size` / `--discard-oldest`;
update `docs/Client.UI.md` Subscriptions tab section to mirror. Fixture:
`OpcPlcFixture` / `OpcPlcProfile` seeds an analog (`StepUp` already
oscillates) and confirms `EURange` is published — extend the profile to
flag noisy nodes. Integration test in
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserts
publish suppression below the deadband threshold. E2E: add a
`-DeadbandValue` stage to `scripts/e2e/test-opcuaclient.ps1` (and a
`deadband` knob to `scripts/e2e/e2e-config.sample.json`) that subscribes,
asserts no spurious updates within the band.
---
#### PR-3: Honor server `OperationLimits` (gap #15)
**Goal**: read `Server.ServerCapabilities.OperationLimits.MaxNodesPerRead /
Write / Browse / HistoryReadData` once after Session activation, cache,
and chunk batch operations to those caps client-side. Today the SDK chunks
on its internal default; against an undersized embedded UA server this
results in `BadTooManyOperations`.
**SDK API**:
- After session open: `Session.ReadAsync` of
`VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead`
+ sibling NodeIds. The SDK exposes `Session.OperationLimits` after
`FetchOperationLimits` is called — prefer that path.
- `Session.FetchOperationLimitsAsync(ct)` (1.5+); fallback: explicit Read.
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — call
`FetchOperationLimitsAsync` post-`OpenSessionOnEndpointAsync`; honour
caps in `ReadAsync`, `WriteAsync`, `BrowseRecursiveAsync`,
`EnrichAndRegisterVariablesAsync`, `ExecuteHistoryReadAsync`.
**Tests**: mock `Session.OperationLimits` to a value below the test batch
size and assert the driver issues N wire calls instead of one.
**Risks**: a zero on the server means "no limit" per Part 5 — don't divide
by zero.
**Docs / fixture / e2e**: new "Server OperationLimits handling"
subsection in `docs/drivers/OpcUaClient.md` documenting the auto-fetch
behaviour, the zero-means-unlimited semantics, and how to override via
options if the server reports an under-truthful value. Fixture: opc-plc
publishes the standard ServerCapabilities tree out of the box — no
container-side change; the `OpcPlcFixture` seed validates the IDs at
collection init. Integration test asserts batch reads chunk to the
fetched cap. No e2e change needed (the script's batch sizes are already
small).
---
#### PR-4: Diagnostics counters (gap #17)
**Goal**: expose per-driver counters on `DriverHealth` (or a sibling
`DriverDiagnostics` surface): publish-request count, notifications-per-
second EWMA, missing-publish-request count, dropped-notification rate,
session resets count. Operators currently see only `LastSuccessfulRead`
+ last error.
**SDK API**:
- `Subscription.Notification` event fires per published notification — bump
a counter
- `Subscription.PublishStateChanged` event for missed-publish detection
- `Session.PublishError` event for channel-level errors
- `Session.SessionClosing`/`SessionConfigurationChanged` for session-reset
attribution
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — instrument hooks; expose via
`IDriver.GetDiagnostics()` or extend `DriverHealth`
- `src/.../Core.Abstractions/IDriver.cs` — confirm where the counter shape
lives; if `DriverHealth` is too rigid, add `IDriverDiagnostics` (mirrors
the Modbus `driver-diagnostics` RPC pattern from #154)
**Tests**: synthetic notification fan-out → assert counters increment;
session close → assert reset count bumps.
**Risks**: counters need to be lock-free hot-path safe; use
`Interlocked.Increment` and a single sliding-window clock per counter.
**Docs / fixture / e2e**: new "Driver diagnostics" section in
`docs/drivers/OpcUaClient.md` enumerating each counter and the event
that bumps it; cross-link to the `driver-diagnostics` Admin RPC
documented for Modbus (#154 pattern). Fixture: no opc-plc change
required. Integration test exercises `IDriverDiagnostics` after
forcing a session close. E2E: extend
`scripts/e2e/test-opcuaclient.ps1` with a "diagnostics snapshot" stage
that asserts publish/notification counters are non-zero after the
subscribe stage.
---
#### PR-5: CRL / revocation handling (gap #20)
**Goal**: explicit revoked-cert handling in `CertificateValidator` plus a
`RejectSHA1SignedCertificates` knob. Today the validator hooks
`BadCertificateUntrusted` only — a revoked cert silently fails as
"untrusted" with no operator-visible distinction.
**SDK API**:
- `CertificateValidator.CertificateValidation` event — inspect
`e.Error.StatusCode` for `BadCertificateRevoked`,
`BadCertificateRevocationUnknown`,
`BadCertificateIssuerRevocationUnknown`,
`BadCertificatePolicyCheckFailed`
- `SecurityConfiguration.RejectSHA1SignedCertificates`,
`SecurityConfiguration.RejectUnknownRevocationStatus`,
`SecurityConfiguration.MinimumCertificateKeySize` — direct config
bool/int knobs already on the SDK type
- `CertificateTrustList.AddCRL` / per-store CRL directories under
`%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\`
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs``BuildApplicationConfigurationAsync`
honours new options, validator handler distinguishes revoked vs untrusted
in the surfaced error message
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
`RejectSHA1SignedCertificates`, `RejectUnknownRevocationStatus`,
`MinimumCertificateKeySize`
**Tests**: feed a SHA1-signed test cert and a revoked cert through the
validator with the new knobs on/off.
**Risks**: PKI directory layout changes — existing deployments need a
migration note.
**Docs / fixture / e2e**: new "Certificate revocation and SHA1 rejection"
subsection in `docs/drivers/OpcUaClient.md` documenting the CRL
directory layout under `%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\`
and the new options (with a migration note for existing PKI stores);
cross-link from `docs/security.md`. Fixture: extend
`OpcPlcFixture` / `Docker/docker-compose.yml` with an optional secured
endpoint variant and a SHA1-signed test cert checked into the test
project's resources for the validator unit test. Integration test
exercises a revoked cert via a local CRL drop. E2E: add a
`-Insecure:$false` smoke stage to `scripts/e2e/test-opcuaclient.ps1`
that asserts a revoked cert produces a distinguishable error message.
---
### Phase 2 — Discovery & curation
#### PR-6: Discovery URL `FindServers` (gap #2)
**Goal**: accept a discovery URL (`opc.tcp://host:4840` pointing at the
LDS or the server's own discovery endpoint) and surface advertised servers
+ endpoints to the operator without manual policy/mode tuple copy.
**SDK API**:
- `DiscoveryClient.CreateAsync(appConfig, new Uri(url), DiagnosticsMasks.None, ct)`
- `DiscoveryClient.FindServersAsync(null, ct)``ApplicationDescription[]`
- `DiscoveryClient.GetEndpointsAsync(null, ct)` per advertised `DiscoveryUrl`
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new internal
`DiscoverServersAsync` helper; extend the Admin-side discovery RPC to
invoke it (driver-diagnostics pattern from #154)
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
`DiscoveryUrl` knob (alternative to explicit `EndpointUrls` — when set
the driver runs `FindServers` at init and feeds the result into the
failover candidate list)
**Tests**: mock `DiscoveryClient` returning two advertised servers each
with three endpoints; assert the candidate list reflects the policy/mode
filter applied client-side.
**Risks**: `FindServers` itself usually requires `SecurityMode=None`
spec out in the doc that the discovery channel is unsecured even when
the data channel will be encrypted.
**Docs / fixture / e2e**: new "Discovery URL (`FindServers`)" section in
`docs/drivers/OpcUaClient.md` with the unsecured-discovery-vs-secured-
data caveat called out; cross-link from `docs/Client.CLI.md` if a
`discover` CLI command surfaces. Fixture: opc-plc already responds to
`FindServers` on the same endpoint — `OpcPlcFixture` adds a discovery
probe at collection init. Integration test exercises the helper against
the live opc-plc container and asserts at least one
`ApplicationDescription` returned. E2E: replace the hard-coded
`-RemoteUrl` stage in `scripts/e2e/test-opcuaclient.ps1` with an
optional `-DiscoveryUrl` mode that picks the first advertised endpoint.
---
#### PR-7: Selective import / namespace remap (gap #7)
**Goal**: per-branch include/exclude rules, namespace-URI remapping, and
re-keyed BrowseNames — the curation surface every commercial aggregator
ships.
**Approach**: extend `OpcUaClientDriverOptions` with a `Curation` section:
- `IncludePaths: string[]` — glob or NodeId-rooted prefix list; only paths
matching are imported
- `ExcludePaths: string[]` — wins over Include (Include is allow-list,
Exclude is block-list)
- `NamespaceRemap: Dictionary<string,string>` — upstream NS URI →
local-side alias for BrowseName generation
- `RootAlias: string` — default `"Remote"`; replaces the hardcoded folder
name today
**SDK API** — none new; this is pure local filtering inside
`BrowseRecursiveAsync` and `EnrichAndRegisterVariablesAsync`.
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs`
- `src/.../OpcUaClient/OpcUaClientDriver.cs`
`BrowseRecursiveAsync` consults the rule set; helper
`MapNamespaceForBrowseName` handles NS remap
**Tests**: synthetic browse tree, exercise include/exclude/remap each
independently and combined; verify the cap accounting in
`MaxDiscoveredNodes` excludes filtered nodes.
**Risks**: glob semantics — pin to a small subset (`*`, `?` only — no
character classes or `**`) to keep the doc + behaviour simple.
**Docs / fixture / e2e**: new "Curation: include/exclude and namespace
remap" section in `docs/drivers/OpcUaClient.md` with worked examples of
each rule kind and the supported glob subset; update
`docs/drivers/OpcUaClient-Test-Fixture.md` "Coverage map" with the new
filtering rows. Fixture: extend `OpcPlcProfile` to enumerate which
upstream namespaces are exercised so curation tests can target them.
Integration test seeds an Include + Exclude + Remap rule and asserts
the local tree reflects the filter. E2E: add a
`-IncludePath` / `-NamespaceRemap` set of params to
`scripts/e2e/test-opcuaclient.ps1` that asserts the local browse depth
matches the rule.
---
#### PR-8: Type definition mirroring (gap #8)
**Goal**: walk the upstream `Types` folder (`ObjectTypes`,
`VariableTypes`, `DataTypes`, `ReferenceTypes`) and project them into the
local address space so downstream UI clients keep type-aware rendering and
structured DataTypes decode correctly.
**SDK API**:
- `Session.NodeCache.FetchNode(typeNodeId)` for type metadata
- `Session.LoadDataTypeSystem` — for structured DataType encoding
- `Session.FetchTypeTree(NodeIdCollection)` — populates the session's
type cache from the server
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new pass-3 in `DiscoverAsync`
that walks `i=86` (Types folder) under the curation rules, registers a
parallel type subtree, and links variables to their TypeDefinition via
HasTypeDefinition references on the address-space builder
- `src/.../Core.Abstractions/IAddressSpaceBuilder.cs` — confirm whether
the builder accepts type nodes; if not, extend it (this likely is a
prerequisite — if so, it gets its own preceding PR-8a)
**Tests**: mock browse returning `BaseObjectType -> DerivedThing`;
assert local builder receives the type node + the HasTypeDefinition link.
**Risks**: significant. Type mirroring touches `IAddressSpaceBuilder`
which is a cross-cutting interface every driver depends on. If
`IAddressSpaceBuilder` already supports type nodes (Galaxy has type-like
templates), reuse that surface; otherwise this PR splits.
**Docs / fixture / e2e**: new "Type mirroring" section in
`docs/drivers/OpcUaClient.md` documenting which type nodes get walked
and how downstream UA clients see the HasTypeDefinition references; also
note in `docs/Client.UI.md` that the Browse tree now shows mirrored
types. Fixture: opc-plc already exposes the standard `Types` folder;
extend `OpcPlcProfile` to assert at least one custom ObjectType is
present. Integration test browses the local Types folder post-discovery
and asserts the upstream type chain landed. No e2e change needed beyond
extending the existing browse stage to walk under `Types`.
---
#### PR-9: Method node mirroring + `Call` passthrough (gap #9)
**Goal**: discover `NodeClass.Method` nodes in the browse pass, expose
them on the local address space, and forward `Call` invocations as
`Session.CallAsync` against the upstream node. The driver already calls
`AcknowledgeableConditionType.Acknowledge` for A&C — generalize that path.
**SDK API**:
- `Session.CallAsync(requestHeader, methodsToCall: CallMethodRequestCollection, ct)`
returning `CallMethodResultCollection`
- Browse already covers Method nodes by lifting the `NodeClassMask`; need
to additionally browse `HasProperty` to discover `InputArguments` /
`OutputArguments` for argument translation
**Files**:
- `src/.../Core.Abstractions/IDriver.cs` — add `IMethodInvoker` capability
interface (this is a NEW capability, not a tweak to an existing one)
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement
`IMethodInvoker.InvokeAsync(string objectId, string methodId,
IReadOnlyList<object?> inputs, ct)`; refactor `AcknowledgeAsync` to
reuse the common path
- `src/.../Server/...` node-manager — wire `IMethodInvoker` to the OPC UA
server's `MethodNode.OnCallMethod` hook so downstream Call requests
reach the driver
**Tests**: mock `Session.CallAsync` returning Good + an output collection;
assert pass-through fidelity. Also assert per-argument `BadInvalidArgument`
codes pass through.
**Risks**: high — adds a new capability interface. Other drivers that
*could* support methods (Galaxy via `OnExecute` scripts, FOCAS via FOCAS
commands) gain a clean extension point but each is its own follow-up.
**Docs / fixture / e2e**: new "Method nodes and Call passthrough"
section in `docs/drivers/OpcUaClient.md` explaining how method calls
flow through the aggregator (input/output argument translation, error-
code passthrough); add a `call` command page to `docs/Client.CLI.md`
covering the new path; mirror in `docs/Client.UI.md` if a UI surface
ships. Fixture: opc-plc already exposes the standard
`Server.GetMonitoredItems` method — `OpcPlcFixture` registers it as the
canonical method-call target. Integration test in
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` invokes
`Server.GetMonitoredItems` through the aggregator. E2E: add a
`-MethodNodeId` stage to `scripts/e2e/test-opcuaclient.ps1` that calls
the method through the local server and asserts the output matches the
direct upstream call.
---
### Phase 3 — Change tracking
#### PR-10: Auto re-import on `ModelChangeEvent` (gap #10)
**Goal**: subscribe to `BaseModelChangeEventType` /
`GeneralModelChangeEventType` on the upstream server's `i=2253` Server
node so when the upstream topology changes (new tag added, type modified)
the driver triggers a `ReinitializeAsync`-style re-import without
operator action.
**SDK API**:
- A second `Subscription` on the Session, monitoring `Server` node
(`ObjectIds.Server`) with an `EventFilter` whose SelectClauses reference
`BaseModelChangeEventType` and (optionally) `GeneralModelChangeEventType`
Changes property
- On notification: enqueue a debounced re-discover (don't react to every
event during a bulk topology edit — coalesce 2-5s window)
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — add `_modelChangeSubscription`
field; new `SubscribeModelChangesAsync` invoked at the end of
`InitializeAsync`; debounce timer that calls `ReinitializeAsync` on the
driver host
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
`WatchModelChanges: bool` (default true) +
`ModelChangeDebounce: TimeSpan` (default 5s)
**Tests**: synthetic event injection on the mock Session's notification
stream; assert one debounced re-import call regardless of N events
arriving in the window.
**Risks**: re-import while a downstream client is mid-browse — needs
serialization on `_gate` like the rest of the driver; document that
clients see a brief gap in the address space during reload.
**Docs / fixture / e2e**: new "Auto re-import on ModelChangeEvent"
section in `docs/drivers/OpcUaClient.md` documenting the debounce window,
the `_gate` serialization, and the brief browse-gap during reload.
Fixture: opc-plc supports runtime topology mutation via the
`addnode`/`addtag` HTTP control endpoint — extend `OpcPlcFixture` with
a helper that triggers a model change. Integration test asserts a
single re-import call after a burst of synthetic model change events.
E2E: add a "topology change" stage to
`scripts/e2e/test-opcuaclient.ps1` that calls the opc-plc control
endpoint, then asserts the local server reflects the new node within
the debounce window.
---
### Phase 4 — Connectivity
#### PR-11: Reverse Connect (gap #1)
**Goal**: support server-initiated client connect for OT-DMZ outbound-only
firewalls. The upstream server connects *to* us on a TCP listener; we
respond as the client. Hard requirement for many regulated plant networks.
**SDK API**:
- `Opc.Ua.Client.ReverseConnectManager` — manages a TCP listener on the
configured port and dispatches incoming reverse-connect requests
- `ReverseConnectManager.AddEndpoint(Uri reverseEndpoint)` — listener URI
e.g. `opc.tcp://0.0.0.0:4844`
- `ReverseConnectManager.WaitForConnection(serverUri, serverUri, ct)`
blocks until the configured server initiates a reverse connect
- `Session.Create(appConfig, reverseConnection, endpoint, ...)`
alternative session-create overload accepting the
`ITransportWaitingConnection` returned by the manager
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
`ReverseConnect: { Enabled, ListenerUrl, ExpectedServerUri }` section
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — when reverse-connect is
enabled, replace the failover sweep with `WaitForConnection` and fall
through into the same session-create path
- New helper `ReverseConnectListener` — owns the manager lifecycle, one
listener per driver-host process (singleton across instances if multiple
reverse-connect drivers are configured)
**Tests**: spin up a `ReverseConnectClient` test against an opc-plc
container started with `--rc opc.tcp://host:4844` to verify end-to-end.
Unit tests mock `ITransportWaitingConnection`.
**Risks**: highest of the plan. Reverse Connect changes the
listen-vs-dial direction; if multiple OpcUaClient driver instances both
listen on the same port the manager must multiplex. opc-plc supports
reverse connect (`--rc` flag) so the integration test pattern from
`docs/drivers/OpcUaClient-Test-Fixture.md` extends cleanly.
**Docs / fixture / e2e**: new "Reverse Connect" section in
`docs/drivers/OpcUaClient.md` (create if missing) documenting the
listener URL config, the OT-DMZ outbound-only use case, and the shared-
listener singleton model; update `docs/drivers/OpcUaClient-Test-Fixture.md`
with the new "Reverse Connect coverage" row. Fixture: extend
`Docker/docker-compose.yml` with an `opc-plc-rc` service variant that
adds `--rc opc.tcp://host.docker.internal:4844`; `OpcPlcFixture` gains
a `[CollectionDefinition]` that wires up the reverse-connect listener
on the test side. Integration test asserts a session opens via the
reverse path. E2E: add a `-ReverseConnect` switch to
`scripts/e2e/test-opcuaclient.ps1` that flips the driver to listener
mode and verifies the bridge stage still passes.
---
### Phase 5 — Historical & redundancy
#### PR-12: `IHistoryProvider.ReadEventsAsync` interface fix + driver impl (gap #12)
**Goal**: extend `IHistoryProvider.ReadEventsAsync` to carry an
`EventFilter SelectClauses` parameter so HistoryRead Events can return
the right field projection, and implement the OPC UA Client passthrough.
**This is a cross-driver concern.** `IHistoryProvider` lives in
`Core.Abstractions` and every driver that opts into history (Galaxy,
OpcUaClient, plus any future historian-backed Tier-A driver) inherits the
default. Changing the signature is source-breaking — coordinate as one PR
that:
1. Adds the `IReadOnlyList<EventFieldProjection>` (or equivalent
abstract `EventFilterSpec`) parameter
2. Updates Galaxy's existing override (currently the only override) to
honour the projection (best-effort — the Galaxy A&E log has a fixed
field set so most projections degrade to the default columns)
3. Lands the OpcUaClient passthrough using `Session.HistoryReadAsync` with
`ReadEventDetails`
**SDK API**:
- `ReadEventDetails { StartTime, EndTime, NumValuesPerNode, Filter }`
- `Session.HistoryReadAsync` is already the call we use for Raw — pass
`new ExtensionObject(new ReadEventDetails { ... })` for events
- `HistoryEvent.Events: HistoryEventFieldList[]` — unwrap into
`HistoricalEvent` records
**Files**:
- `src/.../Core.Abstractions/IHistoryProvider.cs` — interface change
- `src/.../Driver.Galaxy.../*HistoryProvider*.cs` — adjust signature
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement
`ReadEventsAsync`; reuse `ExecuteHistoryReadAsync` shape
- Server-side history facade — propagate the new parameter
**Tests**: integration test against opc-plc with
`--alm` (alarm sim already enabled per the fixture doc) — verify the
SelectClause projection comes back correctly.
**Risks**: the cross-driver interface change is the riskiest single
ergonomic call in this plan. If we can't fit the new parameter without
breaking every driver's `IHistoryProvider` impl, fall back to a sibling
`IEventHistoryProvider` interface and only the OPC UA Client + Galaxy
implement it. **Decide this in the PR review.**
**Docs / fixture / e2e**: new "HistoryRead Events" section in
`docs/drivers/OpcUaClient.md` documenting the `EventFilter`-aware
passthrough; update `docs/Client.CLI.md` `historyread` page to cover
event-mode reads. **Cross-driver doc updates** (this PR adds an
"`IHistoryProvider.ReadEventsAsync` signature change — see
`docs/plans/opcuaclient-plan.md` PR-12" note to every other driver
plan that has a history surface): `docs/plans/abcip-plan.md`,
`docs/plans/ablegacy-plan.md`, `docs/plans/focas-plan.md`,
`docs/plans/s7-plan.md`, `docs/plans/twincat-plan.md`, the Galaxy plan
family (`docs/plans/galaxy-*.md` if/when present, and the LMX equivalent
if it lands), and any Modbus plan. Galaxy is the only existing
implementor and gets a real signature update in this PR; the others
get a heads-up note so future work tracks the new shape. Fixture: opc-
plc runs with `--alm` already (per existing fixture doc) — no compose
change. Integration test issues a HistoryRead Events with a non-default
SelectClause and asserts the projected fields. E2E: extend
`scripts/e2e/test-opcuaclient.ps1` with a "history events" stage
gated on the `--alm` simulator producing at least one event.
---
#### PR-13: Full Aggregate function set (gap #13)
**Goal**: extend `HistoryAggregateType` from the 5 enum values today
(Average/Minimum/Maximum/Total/Count) to the OPC UA Part 13 standard
catalog of 30+ aggregates that historian-class clients expect.
**SDK API**: `ObjectIds.AggregateFunction_*` constants — one per
aggregate. The SDK already exposes them; this is pure mapping work.
Aggregates to add (Part 13 §5):
- `TimeAverage`, `TimeAverage2`
- `Interpolative`
- `MinimumActualTime`, `MaximumActualTime`, `Range`, `Range2`
- `AnnotationCount`, `DurationGood`, `DurationBad`,
`PercentGood`, `PercentBad`
- `WorstQuality`, `WorstQuality2`
- `StandardDeviationSample`, `StandardDeviationPopulation`,
`VarianceSample`, `VariancePopulation`
- `NumberOfTransitions`
- `Start`, `End`, `Delta`, `StartBound`, `EndBound`
- `DurationInStateZero`, `DurationInStateNonZero`
**Files**:
- `src/.../Core.Abstractions/IHistoryProvider.cs` — extend
`HistoryAggregateType` enum (additive — existing values keep their
ordinal)
- `src/.../OpcUaClient/OpcUaClientDriver.cs`
`MapAggregateToNodeId` switch grows; default arm rejects `out of range`
**Tests**: parametrized unit test sweeping every enum value — assert
each maps to a non-null `NodeId` in the SDK's well-known set.
**Risks**: low — this is mapping work. Drivers without a real historian
(everything except Galaxy + OpcUaClient) keep throwing `NotSupported`.
**Docs / fixture / e2e**: extend the "HistoryRead aggregates" section in
`docs/drivers/OpcUaClient.md` with the full Part 13 catalog and which
aggregates require server-side support; update
`docs/Client.CLI.md` `historyread` page enumerating the new
`--aggregate` values. Fixture: opc-plc historian support is limited —
flag in `docs/drivers/OpcUaClient-Test-Fixture.md` that the new
aggregates are unit-tested via the SDK's well-known NodeId set, not
exercised wire-side. Integration test sweeps every enum value and
asserts the mapping; gated-skip for aggregates the live opc-plc image
doesn't honour. No e2e change.
---
#### PR-14: `ServerUriArray` redundant failover (gap #14)
**Goal**: read upstream `Server.ServerArray` /
`ServerStatus.ServerArray` and `ServerRedundancyType.RedundancySupport` at
session activation; when the upstream server advertises non-`None`
redundancy, fail over mid-session on `ServiceLevel` drop without losing
client subscriptions. Today our `EndpointUrls` is a one-shot connect-
attempt list, not a live redundancy group.
**SDK API**:
- `Session.ReadValueAsync(VariableIds.Server_ServerStatus_ServerArray, ct)`
→ URI list
- `Session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct)` polled or
subscribed via MonitoredItem
- Subscribe `Server_ServiceLevel` on the existing alarm subscription so
drops propagate via the publish channel
- On low-`ServiceLevel`: open a parallel session against the next URI in
`ServerArray`, `Session.TransferSubscriptionsAsync(otherSession, ...)`
the live subscriptions, swap `Session` reference
**Files**:
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new
`MonitorServerRedundancyAsync` method; integrate with the existing
`OnKeepAlive` / `SessionReconnectHandler` machinery so reconnect and
redundancy-failover share the subscription-transfer code path
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
`Redundancy: { Enabled, ServiceLevelThreshold (default 200) }`
**Tests**: with two opc-plc containers behind the driver,
artificially drop ServiceLevel on the active one and assert the
secondary takes over; assert subscription handles stay valid.
**Risks**: redundancy is the second-riskiest item after Reverse Connect.
The SDK's `TransferSubscriptions` has known edge cases when the
secondary's `SecureChannel` rejects the source-channel's authentication
token; doc that the secondary must trust the same client cert as the
primary.
**Docs / fixture / e2e**: new "Upstream redundancy (`ServerArray`)"
section in `docs/drivers/OpcUaClient.md` with the ServiceLevel
threshold, the shared-cert prerequisite for `TransferSubscriptions`,
and the ops runbook for forcing a failover; cross-link from
`docs/Redundancy.md` (which today covers OUR server's redundancy —
add a "vs upstream-side redundancy" note). Fixture: extend
`Docker/docker-compose.yml` with a second `opc-plc-secondary` service
on a different port; `OpcPlcFixture` gains a multi-endpoint variant.
Integration test drops the active server's ServiceLevel and asserts
the secondary takes over with subscription handles intact. E2E: add a
`-PrimaryUrl` / `-SecondaryUrl` pair to
`scripts/e2e/test-opcuaclient.ps1` (and matching keys to
`scripts/e2e/e2e-config.sample.json`) that scripts a primary stop +
asserts the bridge stage continues to pass.
---
## Documentation, fixture, and e2e impact
Consolidated index of every doc page, fixture asset, and e2e script touched
by the plan above. Authoritative for review — if a PR's `Docs / fixture /
e2e` line references a path not listed here, that's a checklist gap.
### Driver user docs
- `docs/drivers/OpcUaClient.md` — **create on first PR that needs it
(PR-1)** if not present, then extend with one section per PR-1 through
PR-14 covering: subscription tuning, per-tag deadband, OperationLimits
handling, diagnostics counters, CRL/SHA1, FindServers, curation,
type mirroring, methods, ModelChangeEvent, Reverse Connect, history
events, aggregates, upstream redundancy.
- `docs/drivers/OpcUaClient-Test-Fixture.md` — coverage map updated for
curation (PR-7), Reverse Connect (PR-11), aggregates note (PR-13),
redundancy multi-endpoint variant (PR-14).
- `docs/Client.CLI.md` — extended for subscribe deadband syntax (PR-2),
any `discover` command (PR-6), `call` command (PR-9), `historyread`
event mode (PR-12), `--aggregate` enum expansion (PR-13).
- `docs/Client.UI.md` — extended for Subscriptions tab deadband fields
(PR-2), Browse-tree type rendering note (PR-8), Method-call surface
(PR-9) if it ships.
- `docs/security.md` — cross-link from PR-5 (CRL/SHA1 knobs).
- `docs/Redundancy.md` — cross-link from PR-14 (note distinguishing
server-side redundancy from upstream-side redundancy).
### Fixture assets
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml`
— add `opc-plc-rc` (PR-11) and `opc-plc-secondary` (PR-14) service
variants; optional secured endpoint (PR-5).
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs`
— discovery probe at collection init (PR-6), reverse-connect listener
(PR-11), multi-endpoint variant (PR-14), model-change helper (PR-10).
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs`
— flag noisy analogs for deadband (PR-2), enumerate exercised
namespaces for curation (PR-7), record at least one custom ObjectType
(PR-8).
- New integration tests added per PR; all live under the existing
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/`
collection.
- Test certs (PR-5): SHA1-signed + revoked test fixtures checked into
the unit-test project's resources.
### E2E scripts
- `scripts/e2e/test-opcuaclient.ps1` — new stages added per PR (subscription
tuning PR-1, deadband PR-2, diagnostics PR-4, CRL PR-5, discovery
PR-6, curation PR-7, method call PR-9, topology change PR-10,
reverse connect PR-11, history events PR-12, redundancy failover
PR-14). The script is the single integration point for every
driver-level e2e — keep the stages ordered top-down by phase.
- `scripts/e2e/e2e-config.sample.json` — new keys: `deadband`,
`discoveryUrl`, `includePath`, `namespaceRemap`, `methodNodeId`,
`reverseConnect`, `primaryUrl`, `secondaryUrl`.
- `scripts/e2e/test-all.ps1` — no structural change; the existing
`opcuaclient` block forwards new params after wiring them through
`e2e-config.sample.json`.
### Cross-driver impact (PR-12 — `IHistoryProvider.ReadEventsAsync`)
PR-12 changes the `IHistoryProvider.ReadEventsAsync` signature in
`Core.Abstractions` (or introduces a sibling `IEventHistoryProvider`
— pinned in PR-12 review per Open Question 2). That decision is
source-breaking for every driver that opts into history. PR-12 must
add an explicit "interface change — adopt new signature when this
driver implements `ReadEventsAsync`" note to:
- `docs/plans/abcip-plan.md`
- `docs/plans/ablegacy-plan.md`
- `docs/plans/focas-plan.md`
- `docs/plans/s7-plan.md`
- `docs/plans/twincat-plan.md`
- The Galaxy plan family — `docs/plans/galaxy-*.md` if/when those
pages exist; Galaxy is the only current implementor and gets the
real signature update in PR-12, not just a note.
- The LMX plan — `docs/plans/lmx-*.md` if/when it lands (current state:
the LMX driver's history surface is implicit through Galaxy; revisit
during PR-12 review).
- A Modbus plan page if/when one exists; Modbus does not implement
history today but the heads-up note tracks the cross-driver shape.
The cross-driver note text should be a one-paragraph "Heads up: the
`IHistoryProvider.ReadEventsAsync` interface gained an
`EventFilterSpec` parameter in OpcUaClient PR-12 (`docs/plans/opcuaclient-plan.md`).
If/when this driver implements event-history, adopt the new signature."
This pattern keeps each driver plan stable while the cross-cutting
breakage is owned by one PR.
---
## Skip-rated items (for context)
These featuregaps rows are **Build = No** and intentionally omitted from
the plan above:
| # | Gap | Why we're skipping |
| :---: | --- | --- |
| 3 | Multicast / LDS-ME registration | Server-side responsibility, not aggregator's. |
| 4 | GDS push management (Part 12) | Significant infra; rare for our deployment scale. |
| 11 | HistoryUpdate / Modified / Annotation passthrough | MES backfill scope; defer. |
| 16 | Connection / session pooling for multi-instance scale-out | Premature; current per-instance model is simple and adequate. |
| 18 | Kerberos / OAuth2 / JWT identity | Significant security work; defer until AD integration drives it (separate workstream). |
| 19 | Write attribute scope beyond `Value` | Niche; rarely used in OPC UA practice. |
If any of these get prioritized later they slot cleanly between the phases
above — none have prerequisites among the Build = Yes items.
## Open questions
1. **`ISubscribable` overload vs new method (PR-2)**: per-tag spec
carrier is needed for deadband; do we extend the existing
`SubscribeAsync` overload or add `SubscribeWithSpecsAsync`? The
former is source-breaking but cleaner; the latter is additive but
leaves two parallel paths.
2. **`IHistoryProvider.ReadEventsAsync` shape (PR-12)**: does the
`EventFilterSpec` parameter live on `IHistoryProvider` (one interface,
every driver gets it) or on a sibling `IEventHistoryProvider` (two
interfaces, only event-history drivers implement)? Memory entry
suggests the former; preference depends on whether non-OPC-UA drivers
ever expect to project arbitrary event fields. **Pin this in PR-12
review.**
3. **`IMethodInvoker` capability (PR-9)**: does this become the 9th
capability interface (currently 8/8) or is it folded into
`IWritable` as a method-invoke variant? Adding a 9th interface is
the cleaner model and matches the spec layering.
4. **Type mirroring address-space surface (PR-8)**: does
`IAddressSpaceBuilder` already accept type nodes? If yes, PR-8 is
straightforward; if no, it splits into a prerequisite PR-8a that
extends the builder, then PR-8b for the OPC UA Client wire-up. The
answer determines whether PR-8 ships in Phase 2 or slips to a later
phase.
5. **Reverse Connect listener ownership (PR-11)**: one listener per
driver instance (port collision when multiple reverse-connect
drivers run in the same process) vs one shared listener with a
`expectedServerUri` dispatcher. Shared is the right answer; pin
the singleton lifetime to the driver-host.
6. **Phase 1 ship order**: PR-1, PR-3, PR-4, PR-5 are independent and can
land in parallel. PR-2 depends on the `ISubscribable` interface
decision (Q1) — recommend landing PR-1 first to validate the
`OpcUaSubscriptionDefaults` shape, then PR-2.

807
docs/plans/s7-plan.md Normal file
View File

@@ -0,0 +1,807 @@
# S7 Driver — Implementation Plan
> Source of gap analysis: [featuregaps.md → S7](../featuregaps.md#s7-siemens-s7-3004001200--1500)
>
> Covers Build = Yes items only. Skip-rated rows are noted at the end for context.
## Summary
The S7 driver (`src/ZB.MOM.WW.OtOpcUa.Driver.S7/`) ships a working scaffold over
**S7netplus 0.20**: ISO-on-TCP / S7comm, single-connection-per-PLC (`SemaphoreSlim`),
DB / M / I / Q / T / C address parsing, atomic scalar reads/writes for Bool / Byte
/ I16 / U16 / I32 / U32 / F32, polled `ISubscribable` overlay, `IHostConnectivityProbe`
via `ReadStatusAsync`, and a Snap7-server-backed CI fixture on `localhost:1102`.
The 16 Build = Yes gaps fall into six tractable phases. **The hard one is gap #1
(S7-1500 Optimized DB / Symbolic addressing)** — S7netplus speaks classic S7comm
only and cannot reach optimized DBs at all. Phase 6 calls that out as an explicit
architectural decision: ship the constraint as documentation and the rest as
S7netplus-compatible features, *or* fork to a library that supports S7Plus
(Sharp7-fork, Snap7 v2, custom S7Plus). Phases 1-5 do not depend on that decision
and are landable on the current S7netplus base.
Every PR ships unit-test coverage and — where wire semantics matter — extends the
Snap7-server profile in `Docker/server.py` so the integration fixture exercises
the new path. PRs that need real S7-1500 firmware features the simulator doesn't
mimic (PUT/GET protection, password-tier auth, SZL diagnostic buffer) call that
out and gate the live-firmware test on the dev-box S7-1500 lab rig.
Architectural invariants we explicitly preserve:
- Single connection per PLC; `_gate` (SemaphoreSlim) serializes every PDU.
- Strict address-parse-at-init; bad config fails fast with `FormatException`.
- PUT/GET-disabled mapped to sticky `BadDeviceFailure`, not Polly-retried.
- 100 ms minimum publishing interval (matches CPU mailbox scan reality).
- `WriteIdempotent` per-tag flag is the only retry-policy lever.
## Phased delivery
| Phase | Theme | PRs | Gaps closed |
|------:|-------|-----|-------------|
| 1 | Data-type correctness | PR-S7-A1..A5 | #7, #8, #9, #19 |
| 2 | Performance — multi-tag PDU packing | PR-S7-B1..B2 | #3, #22 |
| 3 | Operability knobs | PR-S7-C1..C5 | #2, #4, #20, #21, #24 |
| 4 | Workflow — symbol import + UDTs | PR-S7-D1..D3 | #5, #6, #10 |
| 5 | Diagnostics & security | PR-S7-E1..E2 | #11, #14 |
| 6 | S7-1500 Optimized DB / Symbolic | PR-S7-F (decision) | #1 |
Phases 1-3 run sequentially because Phase 2 packing and Phase 3 deadbands are
both keyed off the type-decode work in Phase 1. Phase 4 (UDT/symbol import) is
parallelizable with Phase 5; Phase 6 is gated on the library-choice decision
in Open Questions (a).
---
## Per-PR detail
### Phase 1 — Data-type correctness
#### PR-S7-A1 — 64-bit scalar types (LInt / ULInt / LReal / LWord)
Closes gap #9. `Float64`/`Int64`/`UInt64` cases in `S7Driver.ReadOneAsync`/
`WriteOneAsync` currently throw `NotSupportedException`.
- **Files**: `S7Driver.cs` (read + write switch), `S7DriverOptions.cs` (extend
`S7Size` with `LWord` for 8-byte access), `S7AddressParser.cs` (accept `DBL` /
`LD` size suffix; S7netplus encodes 8-byte access via byte-array reads, so the
parser converts `DB1.LD0` to a byte-range read internally).
- **Tests**: unit decode tests for the byte-pattern → `long` / `ulong` / `double`
conversion; Snap7-server profile gets `f64` and `i64` seed types.
- **Risks**: S7netplus's `ReadAsync(string)` does not accept `LD` natively;
fallback path is `Plc.ReadBytes(DataType.DataBlock, db, byteOffset, 8)` then
`BitConverter` with explicit endian flip (S7 is big-endian on the wire,
`BitConverter` is little-endian on x86/x64).
- **Effort**: M (3-4 days incl. tests).
- **Deps**: none.
- **Docs / fixture / e2e**: extends the type-mapping table in `docs/v2/s7.md`
with `LInt` / `ULInt` / `LReal` / `LWord` rows; adds the new sizes
(`LInt`, `ULInt`, `LReal`) to the `read` / `write` cookbook in
`docs/Driver.S7.Cli.md`; updates `docs/drivers/S7-Test-Fixture.md`
§"What it actually covers" to list the new 64-bit types and removes them
from §5 "Data types beyond the scalars"; extends the snap7 seed-type set
in `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py`
with `i64`, `u64`, `f64` cases; adds seeds at known offsets
(e.g. `DB1.DBL40` for i64, `DB1.DBL48` for f64) to
`Docker/profiles/s7_1500.json`; adds `S7_1500Profile` constants for the
new tags + a `Driver_reads_seeded_64bit_batch` smoke test in
`S7_1500SmokeTests`; adds an LInt loopback assertion to
`scripts/e2e/test-s7.ps1`.
#### PR-S7-A2 — STRING / WSTRING / CHAR / WCHAR
Closes gap #8 (string portion). S7 `STRING(n)` is `[max-len][actual-len][bytes...]`
(2-byte header + ASCII). `WSTRING(n)` is 4-byte header + UTF-16BE bytes. `CHAR`
is 1 byte; `WCHAR` is 2 bytes UTF-16BE.
- **Files**: `S7Driver.cs` (new `ReadStringAsync` / `WriteStringAsync` private
helpers using `Plc.ReadBytes` for raw byte-range fetch), `S7DriverOptions.cs`
(already has `StringLength`; add `S7DataType.WString`, `Char`, `WChar`).
- **Tests**: unit tests for header parsing including the "actual-len > max-len"
PLC bug case (clamp on read, reject on write); Snap7 `ascii` seed type already
exists, add `wstring` seed.
- **Risks**: write must respect the configured `StringLength` to avoid overrunning
the DB; mismatched max-len is a common field bug.
- **Effort**: M.
- **Deps**: PR-S7-A1 (byte-range read helper lands there).
- **Docs / fixture / e2e**: extends the type-mapping section in
`docs/v2/s7.md` with `STRING(n)` / `WSTRING(n)` / `CHAR` / `WCHAR`
layouts (2-byte vs 4-byte header, UTF-16BE encoding, the "actual-len >
max-len" PLC bug); extends the `read` / `write` cookbook in
`docs/Driver.S7.Cli.md` with `--type WString` / `--type Char` / `--type
WChar` examples and the `--string-length` flag for WString; updates
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" to list
ascii/wstring/char/wchar; adds `wstring`, `char`, `wchar` seed types to
`Docker/server.py` (existing `ascii` covers STRING); seeds a
`DB1.WSTRING[256]` and a `DB1.CHAR[300]` in
`Docker/profiles/s7_1500.json`; adds `Driver_round_trips_string_types`
smoke test exercising read + write of every variant; adds a string
round-trip assertion to `scripts/e2e/test-s7.ps1`.
#### PR-S7-A3 — DTL / DATE_AND_TIME / S5TIME / TIME / TOD / DATE
Closes gap #8 (date/time portion).
- DTL is 12 bytes: year(u16) / month / day / weekday / hour / minute / second / nanos(u32).
- DATE_AND_TIME (DT) is 8 bytes BCD: yy mm dd hh mm ss msH msL+dow.
- S5TIME is 16-bit BCD with a 2-bit time-base.
- TIME is `Int32` ms since 1972 (S7-300/400) or signed-ms duration (S7-1200/1500).
- TOD is `UInt32` ms since midnight; DATE is `UInt16` days since 1990-01-01.
- **Files**: `S7Driver.cs` + new `S7DateTimeCodec.cs` static class encapsulating
every encode/decode (keep the driver lean; codec is unit-testable in isolation).
- **Tests**: round-trip tests per type with golden byte vectors taken from the
Siemens "STEP 7 V18 — Programming Reference" document. Snap7-server seed
profile gains `dtl`, `dt`, `s5time`, `time` types.
- **Risks**: BCD parsing must reject invalid month/day combinations; PLC programs
occasionally write 0x00 0x00 ... when uninitialized — surface as `BadOutOfRange`
rather than parsing to year 0.
- **Effort**: L (4-5 days incl. all six types and the golden-vector suite).
- **Deps**: PR-S7-A1.
- **Docs / fixture / e2e**: extends `docs/v2/s7.md` with a new "Date / time
types" subsection documenting DTL / DT (BCD) / S5TIME / TIME / TOD /
DATE byte layouts and the S7-300/400 vs S7-1200/1500 TIME-encoding
split; adds `--type Dtl` / `--type DateAndTime` / `--type S5Time` /
`--type Time` / `--type TimeOfDay` / `--type Date` to the
`docs/Driver.S7.Cli.md` cookbook; updates
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" with the
new datetime types and removes "DTL / DATE_AND_TIME" from §5 "Data
types beyond the scalars"; adds `dtl`, `dt`, `s5time`, `time`, `tod`,
`date` seed types to `Docker/server.py` with golden-byte vectors
documented in comments; seeds `DB1.DTL[260]`, `DB1.DT[272]`,
`DB1.S5TIME[280]`, `DB1.TIME[284]`, `DB1.TOD[288]`, `DB1.DATE[292]` in
`Docker/profiles/s7_1500.json`; adds
`S7DateTimeCodecTests` (unit) + `Driver_round_trips_datetime_types`
smoke test; no `scripts/e2e/test-s7.ps1` change required (CLI cookbook
examples cover the manual surface).
#### PR-S7-A4 — Array tags (ValueRank=1)
Closes gap #7. `S7TagDefinition` currently has no array dimension; `MapDataType`
hard-codes `IsArray: false`.
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with `ArrayDim` int?
and `ElementCount` int?), `S7Driver.cs` (read path: detect array tag, issue
one byte-range read covering N elements, slice client-side; write path: same
in reverse), `DiscoverAsync` reports `IsArray: true, ArrayDim: [N]`.
- **Tests**: unit tests for `Array[0..9] of Int` and `Array[0..9] of Real`;
Snap7-server profile adds an array seed type. Round-trip array-write test
proves slice ordering.
- **Risks**: S7-1500 supports multi-dim arrays; declare ValueRank=1 only and
document multi-dim as a follow-up. Array-of-UDT lands with PR-S7-D2.
- **Effort**: M.
- **Deps**: PR-S7-A1 (byte-range reads).
- **Docs / fixture / e2e**: adds an "Array tags (ValueRank=1)" subsection
to `docs/v2/s7.md` documenting `Array[0..N]` syntax + the multi-dim
follow-up note; extends `docs/Driver.S7.Cli.md` with an
`--array-count N` flag in the `read` / `write` cookbook and worked
examples for `Array[0..9] of Int` and `Array[0..9] of Real`; updates
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" to list
array round-trips and removes "arrays of structs" from §5 (struct
arrays land in PR-S7-D2); extends `Docker/server.py` with an `array`
meta-seed-type that takes an inner-type + count and lays out N elements
contiguously; seeds `DB1.ArrayInt[300]` (10×Int) and
`DB1.ArrayReal[320]` (10×Real) in `Docker/profiles/s7_1500.json`;
adds `Driver_round_trips_array_int10` + `Driver_round_trips_array_real10`
smoke tests proving slice ordering; adds an array round-trip assertion
to `scripts/e2e/test-s7.ps1`.
#### PR-S7-A5 — LOGO! 8 + S7-200 V-memory area
Closes gap #19. `S7AddressParser` currently rejects the `V` area letter.
- **Files**: `S7AddressParser.cs` (add `V` case → maps to `S7Area.DataBlock` with
`DbNumber=1` for S7-200 / DbNumber per LOGO! VM-mapping table; document the
conversion), `S7DriverOptions.cs` (note CpuType-dependent meaning of V).
- **Tests**: unit tests for `VW0` / `VD4` / `V0.0` parsing, both S7-200 and
LOGO! conventions; document caller responsibility to set `CpuType.S7200` or
`S7200Smart`.
- **Risks**: LOGO! VM base address differs by firmware (V0=0 vs V0=1024 depending
on block); document the offset table rather than auto-detecting.
- **Effort**: S (1-2 days, mostly parser + tests; no wire changes).
- **Deps**: none.
- **Docs / fixture / e2e**: adds a "LOGO! 8 / S7-200 V-memory" subsection
to `docs/v2/s7.md` covering the `V` area letter, the `S7200` /
`S7200Smart` CpuType pre-requisite, the LOGO! VM-mapping table by
firmware band, and the "V0 = DB1.DBX0.0" semantic; extends the address
grammar cheat sheet in `docs/Driver.S7.Cli.md` with `VW0` / `VD4` /
`V0.0` rows and a `-c S7200Smart` worked example; updates
`docs/drivers/S7-Test-Fixture.md` §"What it does NOT cover" item 4 to
note S7-200 / LOGO! parser coverage now exists at unit level; adds
unit-only `S7AddressParserTests` cases — no Snap7 fixture change
(server.py already exposes DB1, which is where V-memory aliases land);
no `scripts/e2e/test-s7.ps1` change required (live-LOGO! testing is
documented as field-only).
### Phase 2 — Performance (multi-tag PDU packing + block coalescing)
#### PR-S7-B1 — Multi-variable PDU packing
Closes gap #3. `ReadAsync(IReadOnlyList<string>)` currently issues one
`plc.ReadAsync` per tag inside the semaphore — N PDUs for N tags.
- **Files**: `S7Driver.cs` (replace per-tag loop with a packer that builds a
list of `S7.Net.Types.DataItem`, calls `plc.ReadMultipleVarsAsync`, then
fans the results back to the per-tag decoder). Keep the existing per-tag
decode switch — only the wire fetch becomes batched.
- **Tests**: integration test that subscribes to 100 tags and asserts the
packet count seen by the Snap7 server is 1 (or N / packing-budget) rather
than 100. Unit-level test covers packer chunking when the negotiated PDU
size won't fit all items.
- **Risks**: `ReadMultipleVarsAsync` errors are per-item; we must surface
per-tag StatusCodes correctly rather than failing the whole batch on one
bad tag. Packing budget = `negotiatedPduSize - 18 (header) - per_item(12)`,
conservatively cap at 19 items per PDU on a 240-byte PDU.
- **Effort**: L (5-6 days incl. the per-item-error fan-out semantics).
- **Deps**: Phase 1 PRs do not block this — but conflicts in `S7Driver.cs`
are likely, so land Phase 1 first.
- **Docs / fixture / e2e**: adds a "Performance — multi-variable PDU
packing" subsection to `docs/v2/s7.md` describing
`ReadMultipleVarsAsync`, the negotiated-PDU packing budget formula
(`pdu - 18 - 12·N`), the 19-items-per-240-byte-PDU rule of thumb, and
the per-item-error semantics; no `docs/Driver.S7.Cli.md` change (CLI
is single-tag); no Snap7-server seed change required (existing seeds
cover the wire path); adds
`S7MultiVarPduPackingTests` to the unit suite (planner chunking when
items don't fit) + a 100-tag perf integration test
`Driver_packs_100_tags_into_minimum_pdus` that asserts request-count
reduction; no `scripts/e2e/test-s7.ps1` change required.
#### PR-S7-B2 — Block-read coalescing for contiguous DBs
Closes gap #22. Reading `DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` should issue one
6-byte byte-range read against DB1 starting at offset 0, sliced client-side.
- **Files**: `S7Driver.cs` adds a planner pass: group same-DB tags by
contiguous byte ranges (gap-merge threshold = configurable, default 16
bytes; over-fetching 16 bytes is cheaper than one extra PDU). Merged ranges
become a single `Plc.ReadBytes` call; the result is sliced per-tag.
- **Tests**: unit tests for the merge planner (input list → expected ranges);
integration test with 50 contiguous DB words proves wire-level reduction.
- **Risks**: STRINGs / arrays should opt out of merging because the per-tag
byte size is variable. Add an "opaque-size" flag so the planner skips them.
- **Effort**: M.
- **Deps**: PR-S7-B1 (the multi-var packer). The two interact: the planner
emits sum-reads, then the packer puts multiple sum-reads on one PDU.
- **Docs / fixture / e2e**: extends the §"Performance" section in
`docs/v2/s7.md` with a "Block-read coalescing" subsection — the
default 16-byte gap-merge threshold, the opaque-size opt-out for
STRINGs / arrays, and operator guidance for tuning the threshold per
DB; no CLI doc change; no Snap7-server seed change (existing
contiguous DB1 seeds — DBW0 / DBW10 / DBD20 — already exercise
contiguous-merge); adds
`S7BlockCoalescingPlannerTests` (unit) covering the merge planner +
opaque opt-out; adds a 50-contiguous-DBW integration test
`Driver_coalesces_contiguous_DBWs_into_single_byte_range_read` that
asserts wire-level reduction; no `scripts/e2e/test-s7.ps1` change.
### Phase 3 — Operability
#### PR-S7-C1 — PDU size negotiation surfaced
Closes gap #2. S7netplus's `Plc` instance exposes the negotiated PDU size after
`OpenAsync` via `Plc.MaxPDUSize`.
- **Files**: `S7Driver.cs` (read `Plc.MaxPDUSize` after open, store on
`_health`; expose via `GetHealth().Diagnostics["NegotiatedPduSize"]`
this requires adding a `Diagnostics` dictionary to `DriverHealth`, which
is a Core change). Operator-visible via the Admin UI driver-diagnostics
panel that already renders Modbus diagnostic stats.
- **Tests**: integration test asserts the value is non-zero after init.
- **Risks**: `DriverHealth` extension must be backward-compatible — existing
drivers should still compile against the unchanged record. Make the new
property nullable with a default of `null`.
- **Effort**: S.
- **Deps**: Core `DriverHealth` shape change (single PR coordinated with
the Modbus diagnostic surface).
- **Docs / fixture / e2e**: adds a "Diagnostics surfacing" subsection to
`docs/v2/s7.md` documenting the `Diagnostics["NegotiatedPduSize"]`
surface + how it renders in the Admin UI driver-diagnostics panel;
no CLI doc change (CLI doesn't expose diagnostics); updates
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" with a
"negotiated PDU size surfaces in driver health" line; no Snap7
seed-type change (snap7's PDU negotiation is fixed at 240 bytes —
document the fixture's negotiated size in the README); adds
`Driver_exposes_negotiated_pdu_size_post_init` smoke test asserting
the value is non-zero; no `scripts/e2e/test-s7.ps1` change.
#### PR-S7-C2 — TSAP / Connection Type selector
Closes gap #4. S7netplus picks PG-class TSAPs by default; hardened CPUs may
require OP / S7-Basic / Other.
- **Files**: `S7DriverOptions.cs` (new `TsapMode` enum: `Auto` / `Pg` / `Op` /
`S7Basic` / `Other`; `Auto` preserves current behavior. Optional
`LocalTsap` / `RemoteTsap` `ushort?` for explicit override). `S7Driver.cs`
branches on the mode to pick the S7netplus `Plc(CpuType, ...)` constructor
vs the `Plc(string ip, byte rack, byte slot, ushort localTsap, ushort remoteTsap)`
raw-TSAP overload. Document the raw-TSAP table in `docs/v2/s7.md`.
- **Tests**: unit test on the mode → TSAP-byte mapping; live-firmware test
documented but only runnable against the dev-box S7-1500 lab rig.
- **Risks**: wrong TSAP causes connection refused at handshake — same failure
shape as wrong slot. Document the mapping prominently.
- **Effort**: M.
- **Deps**: none.
- **Docs / fixture / e2e**: adds a "TSAP / Connection Type" section to
`docs/v2/s7.md` covering the `TsapMode` enum, the raw-TSAP table
(PG = 0x0100/0x0102, OP = 0x0200/0x0202, S7-Basic = 0x0300/0x0302,
Other = caller-supplied), and the hardened-CPU motivation; adds
`--tsap-mode` and `--local-tsap` / `--remote-tsap` flags to
`docs/Driver.S7.Cli.md`'s common-flags table with a worked example
hitting an OP-class TSAP; no Snap7 seed change (snap7 accepts any
TSAP from the CLI, so the unit-level mapping test is sufficient); no
smoke test change (live-firmware-only); no `scripts/e2e/test-s7.ps1`
change.
#### PR-S7-C3 — Per-tag scan group / publish rate
Closes gap #20. `SubscribeAsync` takes one publishing interval for the whole
list; mixed 100 ms / 1 s / 10 s tags need three subscribe calls today.
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with optional
`ScanGroup` string). `S7Driver.cs` (`SubscribeAsync` partitions the input
list into one poll loop per distinct interval; `PollGroupEngine`-style
internal group, but driver-local — same engine the TwinCAT driver uses).
- **Tests**: unit test with three tags at three rates asserts three independent
poll-tick streams; integration test asserts no group starves the others.
- **Risks**: the `_gate` semaphore still serializes — three poll loops can
contend. Document the contention as part of the "1 connection / 1 mailbox"
invariant; if it bites, follow-up adds a fairness queue.
- **Effort**: M.
- **Deps**: none.
- **Docs / fixture / e2e**: adds a "Per-tag scan groups" subsection to
`docs/v2/s7.md` documenting `S7TagDefinition.ScanGroup`, the multi-rate
partitioning semantics, and the `_gate` contention caveat; no CLI doc
change (CLI is single-tag); no Snap7 seed change required (existing
scalar seeds suffice); adds `S7ScanGroupPartitioningTests` (unit) +
`Driver_three_scan_groups_publish_independently` smoke test that
subscribes 3 tags at 100 ms / 1 s / 10 s rates and asserts
independent tick streams; no `scripts/e2e/test-s7.ps1` change
(subscribe assertion already covers the polling path).
#### PR-S7-C4 — Deadband / on-change with thresholds
Closes gap #21. `PollOnceAsync` currently does `!Equals(prev, current)` only —
no analog deadband.
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with
`DeadbandAbsolute double?` and `DeadbandPercent double?`). `S7Driver.cs`
(`PollOnceAsync` evaluates per-tag deadband for numeric types; non-numeric
types fall through to exact equality).
- **Tests**: unit tests for absolute and percent deadbands at edge cases
(NaN, ±Infinity, sign flip, near-zero percent).
- **Risks**: percent deadband against a zero baseline diverges; document and
fall back to absolute when |baseline| < 1e-6.
- **Effort**: S.
- **Deps**: PR-S7-C3 helpful but not required.
- **Docs / fixture / e2e**: adds a "Deadband / on-change" subsection to
`docs/v2/s7.md` documenting `DeadbandAbsolute` / `DeadbandPercent` per
tag, NaN / ±Infinity / sign-flip / near-zero-percent edge cases, and
the |baseline| < 1e-6 fallback; no CLI doc change (CLI's `subscribe`
already polls on change); no Snap7 seed change; adds
`S7DeadbandTests` (unit) covering all edge cases — no integration test
required since deadband is pre-publish filtering inside the polling
loop; no `scripts/e2e/test-s7.ps1` change.
#### PR-S7-C5 — Pre-flight PUT/GET enablement test
Closes gap #24. We currently surface `BadDeviceFailure` only at first read.
Add a pre-flight check during `InitializeAsync` (after `OpenAsync`) that issues
one trivial read (`MW0` or the configured `Probe.ProbeAddress`) and surfaces
the dedicated diagnostic message before declaring `DriverState.Healthy`.
- **Files**: `S7Driver.cs` (`InitializeAsync` adds the probe read; on
`S7.Net.PlcException` with the PUT/GET-disabled error code, throw a
typed `S7PutGetDisabledException` with a configuration-fix hint).
- **Tests**: integration test toggles a Snap7 simulator quirk that mimics
the PUT/GET-disabled response (Snap7 doesn't model this; gate the test
on a `--with-real-plc` opt-in or document as live-firmware-only).
- **Risks**: pre-flight against a real `Probe.ProbeAddress` requires the
address to exist in the PLC; document that the default `MW0` is fine for
most installs but allow `null` / "skip" for sites that haven't wired one.
- **Effort**: S.
- **Deps**: none.
- **Docs / fixture / e2e**: extends the "PUT/GET must be enabled" section
of `docs/Driver.S7.Cli.md` with the new typed
`S7PutGetDisabledException` message + the "skip pre-flight" knob;
adds the same content as a "Pre-flight PUT/GET enablement" subsection
in `docs/v2/s7.md`; no Snap7 seed change (snap7 doesn't model
PUT/GET-disabled — the test for the success path uses the existing
MW0 seed); adds `Driver_preflight_passes_when_probe_address_seeded`
smoke test; documents the live-firmware test as gated on a
`--with-real-plc` opt-in flag in `docs/drivers/S7-Test-Fixture.md`
§"Follow-up candidates"; no `scripts/e2e/test-s7.ps1` change (probe
test already runs first).
### Phase 4 — Workflow (symbol import + UDTs + instance DBs)
#### PR-S7-D1 — Symbol-table / TIA Portal export browse
Closes gap #5. Operators currently hand-edit `S7TagDefinition` JSON. TIA Portal
exports symbols as **`.s7p` archive → External tags → CSV / SDF**. The lighter
target is the CSV format used by the "Generate source from blocks" exporter.
- **Files**: new `src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/` directory:
- `TiaCsvImporter.cs` — parses TIA Portal "Show all tags" CSV (`Name`,
`Address`, `Data type`, `Comment`, `Visible in HMI`). Output: list of
`S7TagDefinition`.
- `AwlImporter.cs` — best-effort AWL `VAR_GLOBAL` / `DATA_BLOCK` parser
for legacy STEP 7 Classic projects.
- **Files (Admin UI)**: a "Import S7 symbols" button on the Driver Tags tab
that POSTs the file to a new `POST /api/drivers/{id}/import-s7-symbols`
endpoint and reports the diff.
- **Tests**: unit tests with golden-input CSV / AWL fixtures; round-trip
test that imports → produces tags → reads against simulator.
- **Risks**: TIA Portal CSV is locale-dependent (decimal-comma in DE locale).
Detect from the header row and accept both. UDT-typed symbols import as
a placeholder until PR-S7-D2.
- **Effort**: L (5-7 days incl. the Admin UI flow).
- **Deps**: see Open Question (c) — confirm CSV+AWL is the right scope, or
whether `.s7p` / `.zip` archive parsing is required.
- **Docs / fixture / e2e**: adds new doc
`docs/drivers/S7-TIA-Import.md` documenting the supported TIA Portal
CSV format (column names, locale-comma detection, UDT-typed
placeholders) and the AWL `VAR_GLOBAL` / `DATA_BLOCK` parser scope;
cross-links it from `docs/v2/s7.md`'s new "Symbol import" section
and from `docs/Driver.S7.Cli.md` with a future `import` subcommand
hook; adds golden-input fixtures
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Fixtures/sample_tia_export.csv`,
`sample_tia_export_de_locale.csv`, and `sample_step7_classic.awl`;
no Snap7 seed change required (existing DB1 seeds support
the import-then-read round-trip); adds `TiaCsvImporterTests` and
`AwlImporterTests` (unit) + `Driver_imports_csv_then_reads_seeded_tags`
integration test that imports the sample CSV → reads via Snap7;
no `scripts/e2e/test-s7.ps1` change (Admin-UI flow has its own
end-to-end coverage in the Admin UI test suite).
#### PR-S7-D2 — UDT / STRUCT / nested-DB handling
Closes gap #6. Today's tag map is flat scalar-only; UDT-typed DBs are
unusable without hand-flattening every member.
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with `UdtName string?`;
alongside, a new `IReadOnlyList<S7UdtDefinition> Udts` on the options that
declares the layout: name, ordered members `(Name, Offset, S7DataType, ArrayDim?)`).
`S7Driver.cs` fans a UDT-typed tag into per-member sub-tags at `InitializeAsync`,
so the read/write path stays scalar-only.
- **Tests**: unit tests for fan-out with nested UDTs (UDT-of-UDT); integration
test with a Snap7 DB seeded as a UDT-shape byte array proves the fan-out
decodes correctly.
- **Risks**: UDT-of-UDT arbitrary nesting depth — cap at 4 levels and reject
deeper with a clear error. Optimized DBs would let TIA reorder members,
re-introducing gap #1; document that user-defined UDTs require "Optimized
block access" off, same as the general DB rule.
- **Effort**: L (1-2 weeks).
- **Deps**: PR-S7-D1 (symbol importer drops UDT-typed entries with a
placeholder; D2 makes those usable).
- **Docs / fixture / e2e**: adds a "UDT / STRUCT support" section to
`docs/v2/s7.md` documenting `S7UdtDefinition`, the fan-out
semantics, the 4-level nesting cap, and the "Optimized block access
must be off" prerequisite; extends `docs/drivers/S7-TIA-Import.md`
(created in PR-S7-D1) with a UDT-typed-entry section showing how
the importer + `Udts` declaration cooperate; updates
`docs/drivers/S7-Test-Fixture.md` §"What it does NOT cover" item 5 to
remove "UDT fan-out"; extends `Docker/server.py` with a
`udt_layout` meta-seed-type that lays out per-member offsets within
a DB byte range; seeds a `DB1.MyUdt[400]` (e.g. Real + Int + Bool)
in `Docker/profiles/s7_1500.json`; adds `S7UdtFanOutTests` (unit) +
`Driver_fans_out_udt_into_member_tags` integration test covering a
nested-UDT case; adds a UDT-member round-trip assertion to
`scripts/e2e/test-s7.ps1`.
#### PR-S7-D3 — Instance-DB / FB parameter access
Closes gap #10. Multi-instance FBs are addressed symbolically (`MyFB_Instance.MyParam`)
with no fixed absolute DB byte offset visible without a TIA project export.
- **Files**: extends PR-S7-D1's importer to recognize "instance DB" entries
(TIA export shows them with a different "DB type" column value); the
importer translates `MyFB_Instance.MyParam` to the resolved
`DBn.DBW_offset` based on the FB's interface declaration in the export.
- **Tests**: golden-input test with an FB-instance DB export; resolved
addresses match Siemens reference.
- **Risks**: when the FB interface changes (TIA "online change"), instance-DB
layouts shift. Document that re-import is required after any FB-interface
edit. Eventually surface this as a startup warning when the symbol-table
hash differs from the imported snapshot — out of scope for this PR.
- **Effort**: M.
- **Deps**: PR-S7-D1, PR-S7-D2.
- **Docs / fixture / e2e**: extends `docs/drivers/S7-TIA-Import.md` with
an "Instance DBs / FB parameters" section covering the importer's
`MyFB_Instance.MyParam``DBn.DBW_offset` resolution, the "DB type"
column convention, and the "re-import on FB-interface edit" caveat;
adds the same caveat as a paragraph in `docs/v2/s7.md`'s "UDT /
STRUCT" section; adds a golden-input fixture
`Fixtures/sample_tia_export_with_fb_instance.csv` to the integration
tests; no Snap7 seed change required (resolved addresses land in DB1
which the existing seeds back); adds
`InstanceDbResolverTests` (unit) +
`Driver_resolves_fb_instance_then_reads_seeded_member` integration
test; no `scripts/e2e/test-s7.ps1` change (FB-instance lookup is an
import-time concern).
### Phase 5 — Diagnostics & security
#### PR-S7-E1 — CPU diagnostic buffer / SZL reads
Closes gap #11. SZL (System Status List) IDs surface CPU type, firmware
version, cycle-time min/avg/max, and the diagnostic-buffer entries.
- **Files**: `S7Driver.cs` exposes a small set of "system tags" alongside
`Tags` — virtual addresses prefixed `@System.` that the read path
recognizes and dispatches to S7netplus's `ReadSzlAsync` (or, if not
exposed, a raw `Plc.ReadBytes` against the SZL-via-S7comm sub-protocol):
- `@System.CpuType`, `@System.Firmware`, `@System.OrderNo` — SZL 0x0011
- `@System.CycleMs.Min` / `.Max` / `.Avg` — SZL 0x0132 / 0x0432
- `@System.DiagBuffer[0..N]` — SZL 0x00A0 ring-buffer entries
- **Files (discovery)**: `DiscoverAsync` adds a `Diagnostics/` subfolder
with the system-tag set when `S7DriverOptions.ExposeSystemTags = true`.
- **Tests**: unit tests for the SZL response parser (golden bytes); live-
firmware test against the dev-box S7-1500.
- **Risks**: S7netplus's SZL surface is incomplete; may need a raw
`Plc.ReadBytes` against `0x84` register or a small SZL-PDU helper.
- **Effort**: M-L.
- **Deps**: PR-S7-C1 (`DriverHealth.Diagnostics` dictionary already there).
- **Docs / fixture / e2e**: adds a "CPU diagnostics (SZL)" section to
`docs/v2/s7.md` listing the exposed `@System.*` virtual addresses, the
underlying SZL IDs, and the `ExposeSystemTags` opt-in; extends
`docs/Driver.S7.Cli.md` with a worked `read -a @System.CpuType` example
in the cookbook; updates `docs/drivers/S7-Test-Fixture.md` §"What it
does NOT cover" with a note that snap7 does not implement SZL — golden-
byte unit tests cover the parser, live SZL is gated on a real S7-1500;
no Snap7 seed change (snap7 returns a fixed handshake banner that the
test checks for "SZL not supported on simulator" branch); adds
`S7SzlParserTests` (unit) with golden bytes; documents the live SZL
test in `docs/drivers/S7-Test-Fixture.md` §"Follow-up candidates"; no
`scripts/e2e/test-s7.ps1` change.
#### PR-S7-E2 — PLC password / protection-level handling
Closes gap #14. S7-300/400 protection levels 1-3 and S7-1200/1500 connection
mechanisms can require a password on connect.
- **Files**: `S7DriverOptions.cs` (new `Password string?` and `ProtectionLevel`
enum). `S7Driver.cs` calls S7netplus's `SetPassword` (if the API surfaces it
— newer S7netplus versions ship `Plc.SendPassword(string)`; if not, raw-PDU
fallback per Siemens "Communication Function Manual" §5.2).
- **Tests**: live-firmware-gated; password-tier failure modes don't reproduce
in Snap7. Unit-level coverage for the options-binding shape only.
- **Risks**: S7netplus may not expose password auth — fallback is to call into
the lower-level `S7.Net.S7Protocol` types or to fork. Land the options
surface unconditionally, gate the wire path on library support, document
the limitation if the library doesn't oblige.
- **Effort**: M (S if S7netplus ships it; L if we need a fallback path).
- **Deps**: none.
- **Docs / fixture / e2e**: adds a "PLC password / protection levels"
section to `docs/v2/s7.md` documenting the `Password` /
`ProtectionLevel` options + the S7-300/400 levels 1-3 vs S7-1200/1500
connection-mechanism semantics + the "limitation if S7netplus
doesn't ship `SendPassword`" note; adds a `--password` flag to
`docs/Driver.S7.Cli.md`'s common-flags table with a hardened-CPU
worked example; updates `docs/drivers/S7-Test-Fixture.md` §"What it
does NOT cover" with a "password / protection levels not modelled by
snap7" note; no Snap7 seed change (snap7 doesn't enforce protection
levels); adds options-binding unit tests only — no integration test
(live-firmware-only); no `scripts/e2e/test-s7.ps1` change.
### Phase 6 — S7-1500 Optimized DB / Symbolic addressing (decision PR)
#### PR-S7-F — Optimized DB / S7Plus
Closes gap #1. **This is an architectural decision PR, not a code PR.**
S7netplus speaks classic S7comm only. Optimized DBs on S7-1500 (default for
new TIA projects) reorder fields and have no fixed byte offsets — absolute
`DB1.DBW0` reads return `BadDeviceFailure`. Three tracks:
1. **Document the constraint and stay on S7netplus.** Operators must uncheck
"Optimized block access" in TIA Portal for any DB the driver reads. This
is what the test fixture already documents. Effort: S (docs only).
2. **Migrate to a library that supports S7Plus.**
- **Snap7 v2 / `Snap7Net`** — C-library wrapper, supports classic S7comm
only (same limitation as S7netplus). Not a fix.
- **Sharp7 fork** — community fork of Snap7 with **partial** S7-1200/1500
PUT/GET semantics. Still classic S7comm.
- **Custom S7Plus implementation** — Wireshark dissector exists; reverse
engineering is substantial. Effort: ≥ 4 weeks; ongoing protocol-version
maintenance. Risk: Siemens has not published S7Plus.
3. **Embed an OPC UA → OPC UA bridge to the S7-1500's onboard OPC UA server.**
The S7-1500 V2.5+ exposes its own OPC UA server with full symbolic access.
Our `OPC UA Client driver` (already shipping per memory) could read the
target CPU's OPC UA server and re-publish — sidesteps S7Plus entirely.
Effort: S; semantics: requires the customer to license Siemens OPC UA
on the CPU. Most modern S7-1500 deployments already license it.
**Recommendation**: ship Track 1 docs immediately (closes the operator
expectation gap) and Track 3 as the Optimized-DB workflow path (re-uses
existing OPC UA Client driver). Track 2 (S7Plus reverse-engineering) is
out of scope unless a customer pays for it.
- **Files**: `docs/v2/s7.md` (Optimized DB section + how to disable),
`docs/featuregaps.md` row #1 updated to reflect the Track 1+3 decision.
- **Tests**: live-firmware test against the dev-box S7-1500 with optimized
block access toggled both ways, asserting `BadDeviceFailure` vs
successful read.
- **Risks**: Track 3's OPC-UA-Client-bridging needs Admin UI plumbing to
configure; that's a larger workstream tracked separately.
- **Effort**: S (docs + decision); L if Track 2 is taken.
- **Deps**: Open Question (a) below.
- **Docs / fixture / e2e**: rewrites `docs/v2/s7.md` to land a
prominent "Optimized DB constraint" section at the top — explicitly
documents the S7-1200 V4.0+ / S7-1500 default, the
`BadDeviceFailure` shape on absolute `DB1.DBW0` reads against an
optimized DB, the "Uncheck Optimized block access in TIA Portal"
fix, and the recommended **bridge-via-OpcUaClient** pattern with a
worked example (Siemens S7-1500 V2.5+ onboard OPC UA server →
`OpcUaClient` driver → re-publish on the OtOpcUa server's address
space); updates `docs/featuregaps.md` row #1 to reflect the
Track 1+3 decision; updates the "Optimized-DB" line of
`docs/drivers/S7-Test-Fixture.md` §"What it does NOT cover" item 4
to point at the new doc; no CLI doc change (CLI is a probe tool, not
the bridging path); no Snap7 fixture change (snap7 has no Optimized-
DB mode); the live-firmware test toggling Optimized block access on
/ off is recorded as a manual checklist in
`docs/drivers/S7-Test-Fixture.md` §"Follow-up candidates" and gated
behind `--with-real-plc`; if Track 2 is taken later, this PR's doc
surface becomes the migration baseline; no `scripts/e2e/test-s7.ps1`
change.
---
## Documentation, fixture, and e2e impact
Consolidated view of every per-PR `Docs / fixture / e2e` line above, so a
reviewer can see the cross-cutting churn at a glance and so the doc /
fixture / e2e maintainers can sequence their work alongside the code PRs.
### User-facing documentation churn
| PR | `docs/v2/s7.md` | `docs/Driver.S7.Cli.md` | `docs/drivers/S7-Test-Fixture.md` | New / cross-cut docs |
|----|-----------------|-------------------------|------------------------------------|----------------------|
| PR-S7-A1 (LInt/ULInt/LReal/LWord) | extend type-mapping table | new sizes in cookbook | remove "no 64-bit types" | — |
| PR-S7-A2 (STRING/WSTRING/CHAR/WCHAR) | string layout subsection | `--type WString` / `--string-length` | list new types | — |
| PR-S7-A3 (DTL/DT/S5TIME/TIME/TOD/DATE) | "Date / time types" subsection | datetime cookbook entries | list new types | — |
| PR-S7-A4 (arrays) | "Array tags (ValueRank=1)" subsection | `--array-count` flag + examples | list array round-trips | — |
| PR-S7-A5 (V-memory) | "LOGO! 8 / S7-200 V-memory" subsection | grammar table + S7200Smart example | parser coverage note | — |
| PR-S7-B1 (PDU packing) | "Performance — multi-variable PDU packing" subsection | — | — | — |
| PR-S7-B2 (block coalescing) | "Block-read coalescing" subsection | — | — | — |
| PR-S7-C1 (negotiated PDU diag) | "Diagnostics surfacing" subsection | — | "negotiated PDU size" line | — |
| PR-S7-C2 (TSAP) | "TSAP / Connection Type" section | `--tsap-mode` / `--local-tsap` / `--remote-tsap` flags | — | — |
| PR-S7-C3 (scan groups) | "Per-tag scan groups" subsection | — | — | — |
| PR-S7-C4 (deadband) | "Deadband / on-change" subsection | — | — | — |
| PR-S7-C5 (PUT/GET pre-flight) | "Pre-flight PUT/GET enablement" subsection | extend "PUT/GET must be enabled" | mark live-firmware test | — |
| PR-S7-D1 (TIA CSV / AWL import) | "Symbol import" cross-link | future `import` subcommand stub | — | **new `docs/drivers/S7-TIA-Import.md`** |
| PR-S7-D2 (UDT / STRUCT) | "UDT / STRUCT support" section | — | remove "UDT fan-out" | extend `S7-TIA-Import.md` |
| PR-S7-D3 (instance DB) | re-import-on-FB-edit caveat | — | — | extend `S7-TIA-Import.md` |
| PR-S7-E1 (SZL diagnostics) | "CPU diagnostics (SZL)" section | `read -a @System.CpuType` example | "SZL not modelled by snap7" + Follow-up | — |
| PR-S7-E2 (PLC password) | "PLC password / protection levels" section | `--password` flag | "password not modelled by snap7" | — |
| PR-S7-F (Optimized DB / S7Plus) | top-level "Optimized DB constraint" + bridge-via-OpcUaClient worked example | — | point §"What it does NOT cover" at new doc | also updates `docs/featuregaps.md` row #1 |
### Snap7-server fixture seed-type additions per PR
The snap7 simulator at `localhost:1102` (driven by
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py` +
`Docker/profiles/s7_1500.json`) has a `seed_buffer` pump with a fixed type
set — `u8 / i8 / u16 / i16 / u32 / i32 / f32 / bool / ascii`. New PRs need
new seed-type cases in `server.py`, new offsets in `s7_1500.json`, and
matching constants in `S7_1500Profile.cs`. The table below names the
delta for each Build-Yes PR:
| PR | New `server.py` seed types | New `s7_1500.json` seed offsets | `S7_1500Profile.cs` additions |
|----|----------------------------|----------------------------------|-------------------------------|
| PR-S7-A1 | `i64`, `u64`, `f64` | `DB1.DBL40` (i64), `DB1.DBL48` (f64), `DB1.DBL56` (u64) | `SmokeI64Tag` / `SmokeU64Tag` / `SmokeF64Tag` |
| PR-S7-A2 | `wstring`, `char`, `wchar` (existing `ascii` covers STRING) | `DB1.WSTRING[256]`, `DB1.CHAR[300]` | `SmokeWStringTag` / `SmokeCharTag` |
| PR-S7-A3 | `dtl`, `dt`, `s5time`, `time`, `tod`, `date` (golden-byte vectors in comments) | `DB1.DTL[260]`, `DB1.DT[272]`, `DB1.S5TIME[280]`, `DB1.TIME[284]`, `DB1.TOD[288]`, `DB1.DATE[292]` | `SmokeDtl` / `SmokeDt` / `SmokeS5Time` / `SmokeTime` / `SmokeTod` / `SmokeDate` |
| PR-S7-A4 | `array` meta-seed (inner-type + count) | `DB1.ArrayInt[300]` 10×Int, `DB1.ArrayReal[320]` 10×Real | `ArrayInt10Tag` / `ArrayReal10Tag` |
| PR-S7-A5 | none (V-memory aliases land in DB1, which `server.py` already exposes) | none | unit-only — no profile change |
| PR-S7-B1 | none | none (existing scalar seeds suffice for packing) | none — perf integration test reuses scalar tags |
| PR-S7-B2 | none | none (existing contiguous DBW0 / DBW10 / DBD20 already test merge) | none |
| PR-S7-C1 | none | none | none |
| PR-S7-C2 | none (snap7 accepts any TSAP) | none | none |
| PR-S7-C3 | none | none | none |
| PR-S7-C4 | none | none | none |
| PR-S7-C5 | none (existing `MK0` MW0 seed covers success path) | none | none |
| PR-S7-D1 | none (CSV import lands tags pointing at existing seeds) | none | possibly add fixture-pointer constants |
| PR-S7-D2 | `udt_layout` meta-seed (per-member offsets) | `DB1.MyUdt[400]` (Real + Int + Bool layout) | `MyUdtTag` + member tags |
| PR-S7-D3 | none (resolved addresses land in DB1) | none | none |
| PR-S7-E1 | none — snap7 doesn't model SZL; unit-level golden bytes cover the parser | none | none |
| PR-S7-E2 | none — snap7 doesn't enforce protection levels; options-binding unit tests only | none | none |
| PR-S7-F | none — snap7 has no Optimized-DB mode; live-firmware checklist instead | none | none |
### E2E `scripts/e2e/test-s7.ps1` impact
`scripts/e2e/test-s7.ps1` runs the five-assertion CLI loopback (probe /
driver-loopback / forward-bridge / reverse-bridge / subscribe-sees-change)
against `DB1.DBW0` Int16. Build-Yes PRs that add CLI surface get a
matching loopback assertion; PRs that touch only internals or admin-UI
flows do not.
| PR | E2E script change |
|----|-------------------|
| PR-S7-A1 | add LInt loopback assertion (write 0x7FFFFFFFFFFFFFFF, read back) |
| PR-S7-A2 | add string round-trip assertion |
| PR-S7-A3 | none (CLI cookbook covers manual surface) |
| PR-S7-A4 | add array round-trip assertion |
| PR-S7-A5 | none (live-LOGO! field-only) |
| PR-S7-B1 | none |
| PR-S7-B2 | none |
| PR-S7-C1 | none |
| PR-S7-C2 | none (live-firmware-only) |
| PR-S7-C3 | none (subscribe assertion already covers polling) |
| PR-S7-C4 | none |
| PR-S7-C5 | none (probe runs first today) |
| PR-S7-D1 | none (Admin UI has its own e2e) |
| PR-S7-D2 | add UDT-member round-trip assertion |
| PR-S7-D3 | none (import-time concern) |
| PR-S7-E1 | none |
| PR-S7-E2 | none (live-firmware-only) |
| PR-S7-F | none (decision PR; live-firmware checklist instead) |
---
## Skip-rated items (for context)
| # | Gap | Skip rationale |
|---|-----|---------------|
| 12 | AS-Alarms / Alarm_S / ProDiag | Alarms are a separate workstream; no `IAlarmSource` shipped on this driver yet, and the gap analysis flags it as a deferred topic. |
| 13 | CPU Run / Stop control / block download | Security and safety risk. PG-class writes that change CPU state are explicitly out of scope. |
| 15 | S7-1500 Secure Communication / TLS | Significant work; S7netplus has no TLS surface. Reconsider when S7Plus track is taken. |
| 16 | S7-400H redundant H-system support | Rare in our deployment scope. Server-level redundancy (`docs/Redundancy.md`) covers the OPC UA layer; H-system driver-level failover is a separate axis. |
| 17 | Multi-CPU rack parallel sessions | One session per CPU works for the deployments we target; multi-CPU racks are an S7-400 niche. |
| 18 | MPI / Profibus / RFC1006-routed transports | Declining use; brownfield only. S7netplus is Ethernet-only. |
| 23 | Connection-resource budget / parallel jobs | One connection works; premature optimization until a deployment hits the cap. |
---
## Open questions
### (a) Library choice for S7Plus
PR-S7-F gates on this decision. Options:
1. **Stay on S7netplus + document Optimized-DB constraint** (preferred default).
2. **Fork to Sharp7 / Snap7 v2** — does *not* solve the S7Plus / Optimized-DB
problem; both are classic S7comm only. Adopting them buys nothing for this
gap. Reject unless we want it for unrelated reasons.
3. **Custom S7Plus client over Wireshark-dissected protocol** — large effort,
ongoing maintenance risk. Only if a customer is paying.
4. **OPC UA → OPC UA bridge via existing OPC UA Client driver** — sidesteps
S7Plus by re-using Siemens's onboard OPC UA server. Recommended secondary
track.
Decision needed before Phase 6 PR-S7-F kicks off.
### (b) `WriteIdempotent` semantics for new types
The `WriteIdempotent` per-tag flag (decisions #44, #45, #143) governs replay-
safe writes. New types from Phase 1:
- **STRING / WSTRING** — typically idempotent (recipe / message text).
Replay-safe by default? **Need confirmation.** Risk: PLC programs that
treat a new string write as a "new message" event would double-fire.
- **DTL / DT** — usually written from a clock master; replay-safe.
- **Arrays of UDT** — depends on the UDT semantics (recipe = safe, command
block = unsafe). Inherit `WriteIdempotent` from the parent tag, do not
add a per-member flag.
- **64-bit types** — same rule as 32-bit equivalents.
Default: keep `WriteIdempotent = false` for everything. Operators flip per
tag based on PLC program semantics. **No semantic extension needed**, but
document the per-type guidance in `docs/v2/s7.md`.
### (c) Symbol-import file format(s)
PR-S7-D1 ships an importer. Which formats?
- **TIA Portal CSV** (Show all tags / Export) — preferred entry point;
most common. **Confirm.**
- **TIA Portal SDF / Excel** — same data; harder to parse. Skip unless
customer demand emerges.
- **STEP 7 Classic AWL / SCL `.AWL`** — secondary. Useful for legacy
S7-300/400 sites still on Classic. **Include in D1?**
- **`.s7p` / `.zap` project archive** — full TIA project. ZIP-shaped;
symbol export would require unpacking and parsing internal XML. Large
scope. **Defer.**
- **`.udt` / `.SDF` external tag library** — niche; defer unless asked.
Recommendation: PR-S7-D1 ships **TIA CSV** + **AWL** only. Anything else is
a follow-up. Decision needed before Phase 4 work begins.

899
docs/plans/twincat-plan.md Normal file
View File

@@ -0,0 +1,899 @@
# TwinCAT Driver — Implementation Plan
> Source of gap analysis: [featuregaps.md → TwinCAT](../featuregaps.md#twincat-beckhoff-ads)
>
> Covers Build = Yes items only.
## Summary
The TwinCAT driver (`src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/`) ships a solid baseline:
six capability interfaces over `Beckhoff.TwinCAT.Ads` v6 `AdsClient`, native
`AdsTransMode.OnChange` notifications, AMS address parsing, symbol-path parser
with multi-dim subscripts, controller-side browse with system-symbol filtering,
and a 30-case live integration suite against TCBSD + Hyper-V XAR. Twelve gaps
remain rated Build=Yes in `docs/featuregaps.md` and they cluster cleanly into
five themes:
1. **Data-type correctness**`LInt`/`ULInt` silently truncated to Int32
(explicit `// matches Int64 gap` comment in `TwinCATDataType.cs:40`),
`TIME`/`DATE`/`DT`/`TOD` marshalled as raw `UDINT` rather than native UA
types, `ENUM`/`ALIAS` skipped at browse, bit-indexed BOOL writes throw,
multi-dim and whole-array reads not batched.
2. **Performance** — every read is a `ReadValueAsync` call with re-resolved
symbolic name; no Sum commands, no handle caching. Multi-thousand-tag
scans pay symbol resolution + per-tag AMS round-trip cost on every cycle.
3. **Operability**`NotificationSettings(OnChange, cycleMs, 0)` clamps
max-delay to zero with no per-tag override; probe loop only checks
reachability — no cycle-time / jitter / `_AppInfo` / RT-state telemetry.
4. **UDT decomposition**`Structure` is declared in the enum but discovery
skips non-atomic symbols (`AdsTwinCATClient.cs:224`); to expose nested UDT
trees we need TMC-file parsing or runtime data-type table introspection.
5. **Alarms** — no `IAlarmSource` implementation; TC3 EventLogger / AMS port
110 events never surface as OPC UA AC events.
The plan ships as five phases / 12 PRs. Phases 1-3 are all narrow scope and can
land in parallel where dependencies allow. Phase 4 (UDT/TMC) is the largest
single piece of work and is called out as such. Phase 5 (alarms) requires
investigation up front (Beckhoff TC3 EventLogger NuGet availability — see
Open questions).
Hyper-V conflict gating: live integration runs against the TCBSD VM
(`docs/drivers/TwinCAT-Test-Fixture.md`, AmsNetId `41.169.163.43.1.1` at
`10.100.0.128`) since the local Hyper-V XAR can't co-exist with Docker
Desktop. All wire-level tests gate on `[TwinCATFact]` / `[TwinCATTheory]`
and skip cleanly when `TWINCAT_TARGET_NETID` is unset.
## Phased delivery
| Phase | Theme | PRs | Sequencing |
|---|---|---|---|
| 1 | Data-type correctness | 1.1 — 1.5 | Independent; ship in any order |
| 2 | Performance — Sum + handles | 2.1 — 2.3 | 2.3 depends on 2.2 |
| 3 | Operability — max-delay + diagnostics | 3.1 — 3.2 | Independent |
| 4 | UDT decomposition with TMC parsing | 4.1 | Stand-alone; significant scope |
| 5 | TC3 EventLogger alarms | 5.1 | Stand-alone; spike first |
Total: 12 PRs covering the 12 Build=Yes gaps.
Recommended landing order: **Phase 1 (correctness) → Phase 3 (operability) →
Phase 2 (perf) → Phase 5 (alarms) → Phase 4 (UDT)**. Correctness first because
it's cheap and removes fixtures' `Skip("Int64 gap")`-style workarounds.
Operability before perf because the diagnostics surface created in 3.2 makes it
much easier to validate Sum-command throughput claims in 2.1.
## Per-PR detail
### Phase 1 — Data-type correctness
#### PR 1.1 — Int64 fidelity for `LINT` / `ULINT`
**Scope**: Map `LInt`/`ULInt` to `DriverDataType.Int64` (currently truncates to
Int32 per `TwinCATDataType.cs:40` comment "matches Int64 gap"). `MapToClrType`
already returns `typeof(long)`/`typeof(ulong)`; the truncation is purely in the
`ToDriverDataType` extension.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs` — change line 40 to
`=> DriverDataType.Int64;` (drop the gap comment).
- Verify `DriverDataType.Int64` exists in `Core.Abstractions` — if not, add it
(likely scope creep into `ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).
**Beckhoff.TwinCAT.Ads API**: none — the wire-level `AdsClient.ReadValueAsync`
already returns `long`/`ulong` boxed in `result.Value` when called with
`typeof(long)` per `MapToClrType`.
**Test plan**:
- Unit: extend `TwinCATCapabilityTests` — assert `LInt.ToDriverDataType() ==
Int64`, `ULInt.ToDriverDataType() == Int64`.
- Integration: extend `GVL_Primitives` to include an `LINT` (`nLargeCounter`)
seeded with `0x1_0000_0000L` (above Int32 range). Add a `[TwinCATTheory]`
case asserting the value round-trips without truncation. May need a new
`GVL_Primitives.lLong : LINT` symbol if not already present (the existing
16-primitive theory in `TwinCAT3SmokeTests.cs` covers `LInt`/`ULInt` —
inspect what value it seeds and tighten the assertion).
**Effort**: S (half day).
**Deps**: none.
**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` "Data types" table — drop the "marshal as
`UDINT` on the wire" caveat for `LInt` / `ULInt` (this PR keeps Int64 fidelity);
`docs/drivers/TwinCAT-Test-Fixture.md` "Bugs caught by live runs" gains a 4th
entry pinning the truncation regression.
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Primitives.TcGVL` adds
`vLargeCounter : LINT := 16#1_0000_0000` (above Int32 range) + matching
`vLargeCounterU : ULINT`; `tests/.../TwinCatProject/README.md` "GVL_Primitives
numeric seeds" enumerates the new symbols.
- Integration tests: `TwinCAT3SmokeTests.cs` — extend the 16-case
`[TwinCATTheory]` to 17/18 cases covering the new LINT/ULINT seeds; assert
the value round-trips without truncation.
- E2E: no change to `scripts/e2e/test-twincat.ps1` — the bridge script targets
a single DINT counter, untouched by Int64 work.
#### PR 1.2 — TIME / DATE / DT / TOD as native UA types
**Scope**: Stop marshalling `TIME` / `DATE` / `DT` / `TOD` as raw `UDINT`
(`AdsTwinCATClient.cs:278-280`). Map according to IEC 61131-3 semantics:
- `TIME` (ms duration) → `DriverDataType.Duration` (UA `Double` seconds, or
add `Duration` to `DriverDataType` if missing).
- `DATE` (days since 1970-01-01) → `DriverDataType.DateTime` (midnight UTC).
- `DT` (seconds since 1970-01-01) → `DriverDataType.DateTime`.
- `TOD` (ms since midnight) → `DriverDataType.DateTime` (today's date +
offset) or a dedicated `TimeOfDay` type if the abstraction supports it.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs` — update
`ToDriverDataType` mapping for the four IEC time types.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — `MapToClrType`
returns the raw UDINT today; keep that for the wire read but post-process
inside `ReadValueAsync` / `ConvertForWrite` to convert UDINT ↔ `DateTime` /
`TimeSpan`. Symmetrical change in `OnAdsNotificationEx` so subscriptions see
the same shape.
**Beckhoff.TwinCAT.Ads API**: still `AdsClient.ReadValueAsync(symbol,
typeof(uint), ct)`. Beckhoff exposes `PlcOpenDate` / `PlcOpenTimeOfDay` etc.
in `TwinCAT.Ads.TypeSystem` — using those types directly would simplify
conversion but tightens our coupling. Investigate during PR.
**Test plan**:
- Unit: round-trip helpers UDINT-since-epoch ↔ `DateTime` for each variant.
- Integration: add `GVL_Primitives.dCurrentTime : DT` seeded with a known
literal (e.g. `DT#2026-01-15-12:00:00`); assert the driver returns a
`DateTime` matching that instant within 1 s.
**Effort**: M (1-2 days).
**Deps**: none. May expose missing `Duration` in `DriverDataType` enum.
**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` "Data types" section — replace the
"marshal as `UDINT` on the wire — CLI takes a numeric raw value" paragraph
with native syntax (e.g. `read -t DateTime` returns ISO-8601, `write -t Time
-v 00:00:01.500` for IEC TIME duration). New examples for each of the four
IEC time types under `read` / `write`.
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Primitives.TcGVL` adds
`dCurrentTime : DT := DT#2026-01-15-12:00:00`, `tCycleDuration : TIME :=
T#1500ms`, `dToday : DATE := DATE#2026-04-25`, `tShiftStart : TOD :=
TOD#06:30:00`. Existing primitives theory in
`tests/.../TwinCatProject/README.md` § "Type coverage" gets the seed values
documented.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
`Driver_round_trips_TIME_DATE_DT_TOD_as_native_UA_types` `[TwinCATFact]`
reading each variable and asserting the CLR shape (`TimeSpan` / `DateTime`).
Update the existing 16-case primitive `[TwinCATTheory]` to assert native
types instead of raw `UDINT` for these four entries.
- E2E: `scripts/e2e/test-twincat.ps1` unchanged for now (single DINT bridge);
follow-up could add a DT-typed bridge node but it's not on the critical path.
#### PR 1.3 — Bit-indexed BOOL writes (read-modify-write)
**Scope**: Replace the `NotSupportedException` at `AdsTwinCATClient.cs:99-100`
with a read-modify-write sequence: read parent word as `uint`, set/clear bit,
write the word back. Must serialize against concurrent writes to the same
parent word — a single `SemaphoreSlim` keyed on parent symbol path is
sufficient (concurrency on bit writes within the same parent is rare and the
PLC cycle is the natural lower bound on contention anyway).
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — replace `throw`
branch in `WriteValueAsync` with RMW logic mirroring `ReadValueAsync`'s
bit-index path. Add `ConcurrentDictionary<string, SemaphoreSlim>
_bitWriteLocks` keyed on parent symbol.
**Beckhoff.TwinCAT.Ads API**: `AdsClient.ReadValueAsync(parent, typeof(uint))`
+ `AdsClient.WriteValueAsync(parent, modifiedWord)`. Both already used.
**Test plan**:
- Unit: extend `TwinCATReadWriteTests` with a `FakeTwinCATClient` test
covering set + clear of bits 0, 7, 15, 31 of a `uint` parent.
- Integration: add a new `[TwinCATFact]` —
`Driver_round_trips_bit_indexed_BOOL_write_and_read` against
`GVL_Primitives.vWord.4` (the `0xBEEF` word's bit-4); flip to true, read
back as true, flip to false, read back as false.
**Effort**: S-M (1 day).
**Deps**: none. Closes task #181 referenced in the existing `NotSupported`
exception message.
**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `write` section — add an example
`otopcua-twincat-cli write -n ... -s "GVL_Primitives.vWord.4" -t Bool -v
true` and a note explaining the RMW semantics + concurrency caveat (parent
word is locked per write — concurrent bit writes on the same word
serialize). `docs/drivers/TwinCAT-Test-Fixture.md` "Bugs caught by live
runs" updates entry #3 to note that writes now also work (read previously
shipped; write was the gap).
- Fixture (TCBSD PLC project): no schema change required —
`GVL_Primitives.vWord` already exists with seed `0xBEEF`. Tests use bits 4
(clear) and 7 (set) to round-trip.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
`Driver_round_trips_bit_indexed_BOOL_write_and_read` `[TwinCATFact]`. Unit
tests in `TwinCATReadWriteTests` extended via `FakeTwinCATClient` for bits
0/7/15/31 of a `uint` parent.
- E2E: no change.
#### PR 1.4 — Multi-dim and whole-array reads
**Scope**: Expand `ReadValueAsync` / `WriteValueAsync` to handle whole-array
reads via Beckhoff's array marshalling, instead of element-by-element. The
symbol-path parser already produces `TwinCATSymbolSegment.Subscripts` with N
dims; today the driver only reads single elements (one path per request).
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — when a tag
declares `IsArray=true` (extend `TwinCATTagDefinition`), use
`AdsClient.ReadValueAsync(symbol, typeof(int[]))` / `typeof(double[,])` etc.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — surface
`IsArray` + `ArrayDim` through `DriverAttributeInfo` in `DiscoverAsync`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTagDefinition.cs` (if exists,
in `TwinCATDriverOptions.cs`) — add `bool IsArray`, `int[]? ArrayDimensions`.
**Beckhoff.TwinCAT.Ads API**: `AdsClient.ReadValueAsync(symbol, Type, ct)`
accepts CLR array types. For dynamically-sized reads use
`AdsClient.ReadAnyAsync<T[]>(...)` or pass `Array.CreateInstance(elemType,
dims)`. SymbolLoader yields a `Symbol.Category == DataTypeCategory.Array` we
can inspect to autoderive dimensions during discovery.
**Test plan**:
- Unit: parse `Matrix[1,2]` and verify ranking / dimension flow into the
request shape via `FakeTwinCATClient`.
- Integration: extend `GVL_Arrays` with a 5x5 `aReal2D : ARRAY [1..5, 1..5]
OF REAL`; new `[TwinCATFact]` reads the whole array in one call and
verifies element count + values.
**Effort**: M (2-3 days).
**Deps**: none. Sets up the array-shape plumbing the rest of the driver
needs anyway.
**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `read` section — add whole-array example
(`read -s "GVL_Arrays.aReal2D"` returns the full matrix as JSON) plus a
dedicated "Arrays" sub-section calling out 1-D / N-D / array-of-struct
semantics. `docs/drivers/TwinCAT-Test-Fixture.md` "What it actually covers"
list adds the whole-array bullet.
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Arrays.TcGVL` already declares
`ARRAY[1..4,1..4] OF REAL` per `TwinCatProject/README.md` § "Array
coverage". This PR adds a 5x5 `aReal2D : ARRAY [1..5, 1..5] OF REAL`
initialised with a deterministic pattern (e.g. `(i-1)*5 + (j-1)`) so the
whole-array test can assert each element. README "Array coverage" gets the
new symbol.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
`Driver_reads_whole_2D_array_in_one_call` `[TwinCATFact]`. Unit tests
extend `TwinCATSymbolPathTests` for multi-dim subscript shape.
- E2E: no change to `scripts/e2e/test-twincat.ps1` (scalar bridge); a future
array-bridge scenario is captured in the consolidated section below.
#### PR 1.5 — ENUM and ALIAS at discovery
**Scope**: `MapSymbolTypeName` returns `null` for any non-atomic type
(`AdsTwinCATClient.cs:224`), so ENUM and ALIAS symbols are silently dropped
during browse. ENUM is essentially a sized-integer with named members; ALIAS
is a renamed atomic. Both are extremely common in real projects (motor states,
recipe-step IDs, bit-flag groups).
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` —
`MapSymbolTypeName` keyed only on the type name today; switch to inspecting
`symbol.DataType` + `symbol.Category` from `TwinCAT.TypeSystem`. For
`DataTypeCategory.Enum` walk `EnumType.EnumValues` and pick the underlying
base type. For `DataTypeCategory.Alias` resolve `AliasType.BaseType`
recursively until atomic.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/BrowseSymbolsAsync` —
surface enum members so the OPC UA layer can later emit them as
EnumStrings.
**Beckhoff.TwinCAT.Ads API**: `TwinCAT.Ads.TypeSystem.SymbolLoaderFactory`
already returns full `IDataType` objects with `Category`, `EnumType`,
`AliasType`, etc. No new APIs.
**Test plan**:
- Unit: extend `TwinCATSymbolBrowserTests` — fake an enum symbol via
`FakeTwinCATClient`; assert it browses with the underlying base type.
- Integration: add `E_LineState : (Idle, Running, Faulted)` + a GVL instance
variable; new `[TwinCATFact]` browses + reads it as `Int16` (or whatever
the underlying type is).
**Effort**: M (1-2 days).
**Deps**: none. POINTER / REFERENCE / INTERFACE / UNION are explicitly
out-of-scope for this PR — they need real-world demand and a much larger
type-system rework. ENUM and ALIAS are the 80% case.
**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `browse` section — note that ENUM and
ALIAS symbols now appear in the output (previously dropped); add a Data
types row for "Enum (surfaced as underlying integer with EnumStrings)"
and "Alias (resolved to base atomic)". `docs/drivers/TwinCAT-Test-
Fixture.md` "What it actually covers" extends with the enum/alias bullet.
- Fixture (TCBSD PLC project): `PLC/DUTs/E_AxisState.TcDUT` and
`E_Severity.TcDUT` already exist; `PLC/DUTs/T_Temperature.TcDUT` and
`T_MeterPerSec.TcDUT` already exist. `PLC/GVLs/GVL_Enums.TcGVL` already
exposes them at the root per `TwinCatProject/README.md` § "Enum + alias
coverage" — no fixture change needed for this PR. README's "Integration-
test contract" gets a new entry for `GVL_Enums.currentSeverity` /
`currentTemperature` so the new browse assertion has a stable target.
- Integration tests: `TwinCAT3SmokeTests.cs` — new
`Driver_browses_enums_and_aliases_with_resolved_base_types` `[TwinCATFact]`
asserting the four `GVL_Enums` symbols surface with the correct underlying
CLR type (`Int32` for E_AxisState, `Int16` for E_Severity, `Double` for
the LREAL aliases).
- E2E: no change.
### Phase 2 — Performance (Sum commands + handle caching)
#### PR 2.1 — ADS Sum-read / Sum-write
**Scope**: Today `ReadAsync` loops over `fullReferences` issuing one
`ReadValueAsync` per tag (`TwinCATDriver.cs:118-156`). Beckhoff's ADS Sum
commands (`IndexGroup=0xF080..0xF084`) batch N reads/writes into a single AMS
request. `Beckhoff.TwinCAT.Ads` v6 exposes this via
`AdsClient.ReadWriteAsync` with `SumCommand` request envelopes —
specifically `SumSymbolRead` / `SumSymbolWrite` from
`TwinCAT.Ads.SumCommand`. ~10x throughput on multi-thousand-tag scans
according to Beckhoff InfoSys.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — new
`ReadValuesAsync(IReadOnlyList<(string symbol, Type clrType)>, ct)` returning
a parallel array of `(value, status)`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::ReadAsync` — bucket
`fullReferences` by `DeviceHostAddress`, call the new client method per
bucket. `bitIndex` handling stays per-tag (RMW post-step).
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs` — add the
bulk-read / bulk-write surface.
**Beckhoff.TwinCAT.Ads API**:
- `AdsClient.ReadWriteAsync(IndexGroup=0xF080, IndexOffset=count, ...)` for
raw sum-read by handle.
- Higher-level: `TwinCAT.Ads.SumCommand.SumSymbolRead(client, symbols)` /
`SumSymbolWrite(client, symbols, values)` in v6. Verify the exact namespace
during PR — Beckhoff sometimes re-shuffles between minor versions.
- For symbolic (no handle) batching: `SumSymbolReadByName`.
**Test plan**:
- Unit: `FakeTwinCATClient.ReadValuesAsync` fakes the bulk surface; test
ordering preservation, partial-failure mapping, empty-input handling.
- Integration: `[TwinCATFact]` reads 100 declared tags in one call, asserts
value parity with 100 single-call equivalents and measures wall-clock
difference (assert under 50% of the loop baseline).
**Effort**: M-L (3 days).
**Deps**: none (handle caching in 2.2 amplifies the win but isn't required).
**Docs / fixture / e2e**:
- Docs: `docs/v3/twincat-backlog.md` perf note moves out (Sum-commands no
longer deferred) — add a closed-out bullet pointing at this PR. New
performance section in `docs/drivers/TwinCAT-Test-Fixture.md` documenting
the throughput baseline + Sum-command delta. `docs/Driver.TwinCAT.Cli.md`
doesn't expose Sum directly to the user — the CLI still drives one symbol
per call — so no CLI doc change.
- Fixture (TCBSD PLC project, primary fixture-extension surface): add a new
`PLC/GVLs/GVL_Perf.TcGVL` declaring `aTags : ARRAY[1..1000] OF DINT` plus
a `MAIN` rung (or new `FB_PerfChurn` POU) that increments each element on
a rotating subset. `TwinCatProject/README.md` § "Required project state"
gains a "Performance scenarios" subsection documenting the 1000-tag GVL.
- Integration tests: new perf test
`Driver_sum_read_1000_tags_beats_loop_baseline_by_5x` (`[TwinCATFact]`,
perf-tier — guarded behind a separate `TWINCAT_PERF=1` env flag so CI
noise from VM jitter doesn't flap the suite). Unit tests cover ordering,
partial-failure mapping, empty-input via `FakeTwinCATClient.ReadValuesAsync`.
- E2E: `scripts/e2e/test-twincat.ps1` unchanged for the canonical bridge;
perf scripts live alongside as a separate `scripts/perf/twincat-sum.ps1`
if/when introduced (deferred — integration test is sufficient).
#### PR 2.2 — Handle-based access with caching
**Scope**: Cache `AdsClient.CreateVariableHandleAsync` results so per-read
overhead drops from "resolve symbolic name + read by name" to "read by handle"
— smaller AMS payloads, no name resolution on each call. Cache lifetime is
process-scoped; eviction is via the PR 2.3 invalidation listener. Until 2.3
ships the cache must be cleared on `AdsClient` reconnect (the existing
auto-reconnect path in `EnsureConnectedAsync`).
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — add
`ConcurrentDictionary<string, uint> _handleCache`. Wrap reads/writes through
`EnsureHandleAsync(symbolPath)` that hits the cache or calls
`CreateVariableHandleAsync`. On `AdsErrorCode.DeviceSymbolVersionInvalid`
(0x710 / 1808) evict the entry and retry once.
- Dispose path: `DeleteVariableHandleAsync` for every cached handle on
`AdsClient.Dispose` to be a good citizen with the runtime.
**Beckhoff.TwinCAT.Ads API**:
- `AdsClient.CreateVariableHandleAsync(string symbol, ct)` → returns
`ResultHandle` with `.Handle` (uint).
- `AdsClient.ReadAnyAsync<T>(IndexGroup=0xF005, IndexOffset=handle, ct)`
reads by handle.
- `AdsClient.WriteAnyAsync(IndexGroup=0xF005, IndexOffset=handle, value, ct)`.
- `AdsClient.DeleteVariableHandleAsync(uint handle, ct)`.
**Test plan**:
- Unit: `FakeTwinCATClient` records handle-create / read-by-handle calls;
test asserts second read of same symbol uses cached handle (zero new
creates).
- Integration: subscribe + read 50 tags, capture AMS round-trips via probe
counter, assert the second pass uses ~50% of the bytes (handle = 4 bytes
vs symbol path = N bytes).
**Effort**: M (2 days).
**Deps**: combines with PR 2.1 for sum-read-by-handle (highest perf path).
Without 2.3, handles can go stale after an online change — call out the
caveat in driver options and add a manual `FlushOptionalCachesAsync` invocation
that wipes the handle cache.
**Docs / fixture / e2e**:
- Docs: `docs/drivers/TwinCAT-Test-Fixture.md` perf section gets a paragraph
noting that handles drop AMS payload size for repeated reads (4 bytes vs.
N-byte symbol path); call out the staleness caveat (online-change
invalidation lands in 2.3). `docs/Driver.TwinCAT.Cli.md` adds a brief note
in the `subscribe` / `read` sections that handles are cached transparently
— no user-visible flag.
- Fixture (TCBSD PLC project): no change required — handle caching is
observable via byte-counter on the wire, not via PLC-side state. The
perf-scenario `GVL_Perf.aTags` from PR 2.1 doubles as the exercise target.
- Integration tests: new
`Driver_handle_cache_avoids_repeat_symbol_resolution` `[TwinCATFact]`
reads the same 50 symbols twice; asserts second pass uses cached handles
(probed via diagnostics counters from PR 3.2 if shipped, otherwise via a
test-only hook on `AdsTwinCATClient`). Unit tests on
`FakeTwinCATClient.HandleCacheTests` assert second read of same symbol
triggers zero new handle creates.
- E2E: no change.
#### PR 2.3 — Symbol-version invalidation listener
**Scope**: TwinCAT publishes a "symbol table version changed" notification on
ADS Index Group `ADSIGRP_SYMVAL_BYHND` (or rather, version bumps land via
`SystemServiceLoadFile` style notifications + `SymbolVersion` reads). When the
PLC takes an online change, all cached handles are silently invalidated; the
next read returns `DeviceSymbolVersionInvalid` if you're lucky and a wrong
value if you're not. We register a notification on the symbol-version index
and wipe the handle cache on bump.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — on connect,
call `AddDeviceNotificationAsync(ADSIGRP_SYM_VERSION, 0, length=1, ...)`
with `AdsTransMode.OnChange`. On callback, clear `_handleCache` + log.
**Beckhoff.TwinCAT.Ads API**:
- `AdsClient.AddDeviceNotificationAsync(uint indexGroup, uint indexOffset,
int length, NotificationSettings, object userData, ct)` — the raw,
index-group-based variant (not the symbol-name `Ex` variant we use today).
- Index group: `AdsReservedIndexGroup.SymbolVersion` (0xF008). One byte
payload that's the current symbol-version counter. Confirm during PR — open
question (c) below.
**Test plan**:
- Unit: extend `TwinCATNativeNotificationTests` — `FakeTwinCATClient` exposes
a `FireSymbolVersionChange()` method; test asserts handle cache is cleared
and subsequent reads recreate handles.
- Integration: `[TwinCATFact]` triggers an online change on the TCBSD project
(rebuild a GVL with one new variable + login activate) — needs a project
helper that automates the online-change. May ship behind a manual gate
(`[TwinCATFact(Reason="requires-manual-online-change")]`) initially.
**Effort**: M (2 days).
**Deps**: PR 2.2 (no point invalidating an empty cache). Confirm
`SymbolVersion` index-group constant in `Beckhoff.TwinCAT.Ads` v6 — open
question (c) below.
**Docs / fixture / e2e**:
- Docs: `docs/drivers/TwinCAT-Test-Fixture.md` section on "What it does NOT
cover" — drop the implicit "online-change handling" gap. New paragraph in
the perf section noting handle cache is now self-invalidating.
`docs/Driver.TwinCAT.Cli.md` no change (transparent to CLI user).
- Fixture (TCBSD PLC project): no schema change. Operator workflow gains an
online-change drill — `TwinCatProject/README.md` adds a § "Online-change
test scenario" describing the steps (open project, add a dummy variable
to `GVL_Perf`, "Login + Activate" → triggers the symbol-version bump).
This is the manual gate for the integration assertion.
- Integration tests: new `Driver_invalidates_handle_cache_on_symbol_version_bump`
`[TwinCATFact]` — initially gated `[TwinCATFact(Reason="requires-manual-online-change")]`
until automation lands. Unit tests cover the callback path via
`FakeTwinCATClient.FireSymbolVersionChange()`.
- E2E: no change.
### Phase 3 — Operability
#### PR 3.1 — Per-tag MaxDelay tuning
**Scope**: Today `NotificationSettings` is hard-coded as `(OnChange, cycleMs,
0)` (`AdsTwinCATClient.cs:144-145`). MaxDelay=0 means "fire as soon as the
change is detected, no coalescing"; for bursty high-frequency signals this
floods the OPC UA subscription queue. Surface MaxDelay as a per-tag option
(default 0 to preserve current behavior).
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs` — add
`int? MaxDelayMs` to `TwinCATTagDefinition`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::SubscribeAsync` —
pass through to client.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs::AddNotificationAsync`
— accept `int maxDelayMs`, plumb into `NotificationSettings(...,
cycleMs, maxDelayMs)`.
**Beckhoff.TwinCAT.Ads API**: `NotificationSettings(AdsTransMode mode, int
cycleTime, int maxDelay)` — both args in milliseconds per Beckhoff InfoSys
`tcadsnetref/7313319051`.
**Test plan**:
- Unit: extend `TwinCATNativeNotificationTests` — assert the plumbed
`maxDelayMs` lands on `NotificationSettings`.
- Integration: subscribe to `GVL_Fixture.nCounter` with `MaxDelayMs=500`;
assert delivery rate is ≤ 2 Hz even when PLC cycle is 10 ms.
**Effort**: S (half day).
**Deps**: none.
**Docs / fixture / e2e**:
- Docs: `docs/Driver.TwinCAT.Cli.md` `subscribe` flag table — add `--max-delay-ms`
with default `0` and a note that nonzero coalesces high-frequency PLC
signals. Update the description of `-i` / `--interval-ms` to disambiguate
cycle vs. max-delay (both pass through to `NotificationSettings`).
`docs/drivers/TwinCAT-Test-Fixture.md` "Notification coalescing under
jitter" caveat — noting per-tag MaxDelay is now configurable.
- Fixture (TCBSD PLC project): no change required — `GVL_Fixture.nCounter`
already increments on every 10 ms cycle (see `MAIN.TcPOU`), so the test
can drive a 100 Hz change rate and verify ≤ 2 Hz delivery with
`MaxDelayMs=500`. README "Required project state" gets a one-line note
that the counter doubles as the coalescing-test driver.
- Integration tests: new `Driver_coalesces_notifications_at_max_delay`
`[TwinCATFact]` subscribes to `GVL_Fixture.nCounter` with `MaxDelayMs=500`
and asserts delivered-event count ≤ 3 over a 1 s window.
- E2E: `scripts/e2e/test-twincat.ps1` `Test-SubscribeSeesChange` is a
one-shot subscribe; no change. A future high-rate variant could test
coalescing end-to-end through the OPC UA bridge but it's not on the
critical path.
#### PR 3.2 — Cycle-time / jitter / PLC-state diagnostics
**Scope**: Probe loop today only checks reachability via `ReadStateAsync`
(`TwinCATDriver.cs::ProbeLoopAsync`). Surface cycle-time, jitter, and online-
change counter as health signals via the standard `_AppInfo` /
`TwinCAT_SystemInfoVarList._AppInfo` GVL (the same one we filter out of
discovery). Specifically:
- `_AppInfo.OnlineChangeCnt` (UDINT) — incremented on every online change.
- `_AppInfo.AppName` (STRING) — TC project name, useful for
cross-instance identification.
- `_TaskInfo[1].CycleTime` (UDINT, 100 ns units) — the configured PLC cycle.
- `_TaskInfo[1].LastExecTime` (UDINT, 100 ns units) — most recent measured
cycle execution; jitter is the delta against `CycleTime`.
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::ProbeLoopAsync` —
augment success path to also read these four symbols. Surface via a new
`TwinCATDeviceDiagnostics` record on `DeviceState`. Emit through
`IDriverDiagnostics` (the cross-driver diagnostics surface introduced for
Modbus prohibition events — task #154).
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs` — leave
the filter as-is for the user-visible browse; the probe path reads system
symbols directly without going through discovery.
**Beckhoff.TwinCAT.Ads API**: still `AdsClient.ReadValueAsync(symbol, type,
ct)`. The symbols are read by name, not by index group, so no new API.
**Test plan**:
- Unit: `FakeTwinCATClient` exposes `SetSystemSymbolValue(string name, object
value)` so tests can drive the diagnostics surface deterministically.
- Integration: `[TwinCATFact]` connects to TCBSD, asserts the diagnostics
block populates `CycleTimeMs > 0` and `OnlineChangeCnt >= 0` within one
probe interval.
**Effort**: M (1-2 days).
**Deps**: confirm `IDriverDiagnostics` shape from existing Modbus diagnostics
RPC (task #154 in MEMORY); it should be reusable.
**Docs / fixture / e2e**:
- Docs: new section "Diagnostics" in `docs/drivers/TwinCAT-Test-Fixture.md`
documenting the four exposed signals (cycle time, jitter, online-change
counter, app name) and where they surface in the cross-driver
diagnostics RPC. `docs/Driver.TwinCAT.Cli.md` `probe` section gains a
"Health probe" sub-section noting the same symbols can be read directly
via `probe -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"`
(the existing example) plus the new `_TaskInfo[1].CycleTime` /
`LastExecTime`. Add `docs/v3/twincat-backlog.md` cross-link confirming
cycle-time/jitter no longer deferred.
- Fixture (TCBSD PLC project): no change required — `_AppInfo` and
`_TaskInfo[1]` are TwinCAT system GVLs, present on every runtime. The
`TwinCATSystemSymbolFilter` already drops them from user browse;
`TwinCatProject/README.md` adds a one-line "These symbols are read by
the probe loop, not project-defined" callout.
- Integration tests: new `Probe_loop_surfaces_cycle_time_and_online_change_count`
`[TwinCATFact]` asserts the diagnostics record populates within one
probe interval against TCBSD. Unit tests via `FakeTwinCATClient.SetSystemSymbolValue`
drive the diagnostics surface deterministically.
- E2E: no change. Future enhancement could expose driver diagnostics via a
CLI subcommand (`otopcua-twincat-cli diagnostics -n ...`) — captured in
the consolidated section below as a follow-up.
### Phase 4 — UDT decomposition with TMC parsing
#### PR 4.1 — Nested UDT browse via TMC parsing
**Scope**: Largest single piece of work in the plan. `TwinCATDataType.Structure`
exists but `BrowseSymbolsAsync` skips non-atomic symbols
(`AdsTwinCATClient.cs:224`); to expose nested UDT trees we either:
1. **Online**: walk the `IDataType` tree returned by `SymbolLoaderFactory` —
each `IStructType` exposes `SubItems` recursively. This is what
`Beckhoff.TwinCAT.Ads` v6's TypeSystem already gives us at runtime; we just
never recursed.
2. **Offline (TMC file)**: parse the TwinCAT Module Class XML file the project
compiles to (`*.tmc`), build a type catalogue, drive discovery from it
without requiring a live runtime.
We ship the **online** path first (PR 4.1) because it covers 100% of the case
where the runtime is reachable, and `SymbolLoaderFactory` already does the
heavy lifting. TMC offline parsing is deferred to a hypothetical PR 4.2 if a
disconnected-discovery use case emerges (unlikely; live integration tests
demonstrate runtime is always available in our deployments).
**Files**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` —
`BrowseSymbolsAsync` recurses into `IStructType.SubItems`, yielding one
`TwinCATDiscoveredSymbol` per leaf with the dotted instance path
(`MyStruct.Inner.Field`). For arrays-of-structs, expand element-by-element
up to a configurable bound (default 1024) — beyond that, expose only the
array root with `IsArray=true`.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::DiscoverAsync` —
fold the recursed structure into the existing `Discovered/` folder tree
using `IAddressSpaceBuilder.Folder` for each struct member.
- New: `TwinCATTypeWalker.cs` — pure helper that takes an `IDataType` and
yields `(instancePath, atomicType, readOnly)` tuples. Unit-testable without
touching `AdsClient`.
**Beckhoff.TwinCAT.Ads API**:
- `TwinCAT.TypeSystem.IStructType` — `SubItems` (collection of
`IMember`); each member has `BaseType`, `Name`, `Offset`.
- `TwinCAT.TypeSystem.IArrayType` — `Dimensions`, `BaseType`.
- `TwinCAT.TypeSystem.IEnumType` — handled in PR 1.5 (atomic surface).
- `TwinCAT.TypeSystem.IAliasType.BaseType` — recurse until atomic.
**Test plan**:
- Unit: new `TwinCATTypeWalkerTests` — feed synthetic `IDataType` trees,
assert the flattened paths and types.
- Integration: extend `GVL_Plant` (already has `Line1.Stations[1].Axes[1].Motor`
per `TwinCAT3SmokeTests.cs`) — the existing `Driver_reads_deeply_nested_UDT_path`
test reads a known-leaf path; add a new test that browses into the same
GVL and asserts the entire tree shape matches expectation. Should yield
~50+ leaves.
**Effort**: L (4-5 days). Most of the cost is in the addressspace-builder
folder/variable plumbing, not the type walking itself.
**Deps**: PR 1.5 (ENUM/ALIAS) — without it, struct members of enum type
silently drop. PR 1.4 (whole-array reads) is helpful but not blocking.
**Docs / fixture / e2e**:
- Docs: this is the **largest doc-write of the plan**.
`docs/Driver.TwinCAT.Cli.md` gains a new top-level "UDT decomposition"
section explaining the dotted-instance browse syntax (`MyStruct.Inner.
Field`), array-of-struct expansion bound, and how members surface via
`browse`. The existing `read` example "Nested UDT member" gets expanded
with a multi-level case targeting the plant hierarchy. `docs/drivers/
TwinCAT-Test-Fixture.md` "What it actually covers" gets a UDT bullet
per-member rather than per-leaf. Update `docs/v3/twincat-backlog.md` —
remove the implicit UDT-decomposition gap.
- Fixture (TCBSD PLC project, primary fixture-extension surface): the
existing `GVL_Plant.Line1.Stations[1..3].Axes[1..4]...` 5-level
hierarchy already provides ~50+ leaves per `TwinCatProject/README.md`
§ "5-level plant hierarchy" + § "Live value churn". This PR may add a
few **edge cases** to stress the type walker:
- `PLC/DUTs/ST_NestedFlags.TcDUT` — struct containing a BIT-packed
member (e.g. `Flags : DWORD` with named bit-mask aliases).
- `PLC/DUTs/ST_RecursiveCap.TcDUT` — struct with a self-pointer (must
be capped by the type walker, not infinite-recurse). Demonstrates
POINTER skip behavior.
- Add an `ARRAY [1..2000] OF ST_AlarmRecord` to exercise the
`MaxArrayExpansion` (default 1024) cutoff.
README § "Complex hierarchy" gets the new edge-case DUTs documented.
- Integration tests: new `TwinCATTypeWalkerTests` (unit) feeding synthetic
`IDataType` trees. Live: `Driver_browses_full_plant_hierarchy_yields_50_plus_leaves`,
`Driver_caps_array_of_struct_expansion_at_configured_bound`,
`Driver_handles_self_referential_struct_without_recursion` against the
new edge-case DUTs.
- E2E: `scripts/e2e/test-twincat.ps1` could gain a UDT-bridge scenario
(`-BridgeNodeId` pointing at `GVL_Plant.Line1.Stations[1].Axes[1].Motor.
Temperature`) but this requires the OPC UA server's address-space to
reflect the decomposed tree — keep as a follow-up after server-side
rendering ships in v3.
### Phase 5 — TC3 EventLogger alarms
#### PR 5.1 — `IAlarmSource` via TC3 EventLogger
**Scope**: TwinCAT 3.1 build 4022+ ships TcEventLogger as a system service
exposing alarms/events on AMS port 110 (`AMSPORT_EVENTLOG`). Implement
`IAlarmSource` over that interface so PLC alarms surface as OPC UA AC events.
**Open question (b) below** drives the implementation: does Beckhoff publish
a managed wrapper, or do we hit AMS port 110 directly?
If a managed wrapper exists:
- `Beckhoff.TwinCAT.Ads.TcEventLogger` (or similar) — subscribe via
`EventLogger.AlarmRaised` event.
If not (likely — InfoSys docs lean on `TcCOM` C++ APIs):
- Open a second `AdsClient` connection to port 110 via
`_secondaryClient.Connect(netId, 110)`.
- Use `AddDeviceNotificationAsync` on the alarm-list index group
(`ADSIGRP_TCEVENTLOG_ALARMS`, exact constant TBD during spike).
- Decode the binary event payload into `AlarmEvent` records (severity,
source, message, time-of-occurrence, ack state).
**Files**:
- New: `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs`
— implements `IAlarmSource` (currently used by Galaxy / Wonderware).
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — declare
`IAlarmSource` interface, delegate to the helper.
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs` — new
`bool EnableAlarms` (default `false` until production-validated).
**Beckhoff.TwinCAT.Ads API**: TBD pending spike. Falls back to raw
`AdsClient.AddDeviceNotificationAsync` on port 110 if no managed wrapper.
**Test plan**:
- Unit: fake event-logger feeds synthetic alarms; assert `IAlarmSource`
surface raises events with correct shape.
- Integration: TCBSD project gains an `Alarm.Raise(...)` call site on a GVL
bool transition; new `[TwinCATFact]` subscribes via the driver, toggles the
trigger, asserts the alarm appears in the source within 5 s.
**Effort**: L (4-5 days), most of which is the spike. If no managed wrapper
exists, add another L (3-4 days) to implement the binary protocol decoder.
**Deps**: spike answer to open question (b) — surface that as an explicit
investigation PR before committing to the build.
**Docs / fixture / e2e**:
- Docs: **new file** `docs/drivers/TwinCAT.md` (the existing
`TwinCAT-Test-Fixture.md` is fixture-only) covering the alarm
configuration surface — `EnableAlarms` option, AMS port 110 routing,
severity / source / message decode, OPC UA AC mapping. Spike output
goes to `docs/v3/twincat-eventlogger-spike.md` per open question (b).
`docs/Driver.TwinCAT.Cli.md` gains a new `alarms` subcommand (subscribe
+ print stream) mirroring the OPC UA Client CLI's `alarms` verb.
`docs/drivers/TwinCAT-Test-Fixture.md` "Alarms / history" caveat
removed; capability matrix gets `IAlarmSource = yes`.
- Fixture (TCBSD PLC project, primary fixture-extension surface): add
`PLC/POUs/FB_AlarmHarness.TcPOU` that calls `FB_TcLogEvent` (or
equivalent TC3 EventLogger PLC API) on a 5 s tick, raising / clearing
a known event class. New `PLC/GVLs/GVL_Alarms.TcGVL` exposes the
trigger booleans the test toggles. `TwinCatProject/README.md` § new
"Alarm scenarios" subsection documents the event class IDs + severity
+ cleared-on transitions. The existing `ST_Alarm` DUT remains for
PLC-level data; the EventLogger is the AC source.
- Integration tests: new `TwinCATAlarmIntegrationTests.cs` —
`Driver_raises_alarm_event_when_PLC_logs_event` `[TwinCATFact]`
toggles the trigger via `WriteAsync`, asserts the alarm appears in
`IAlarmSource.AlarmRaised` within 5 s. Includes a clear-event variant.
Unit tests via fake event-logger feed synthetic alarms.
- E2E: `scripts/e2e/test-twincat.ps1` gains a `Test-AlarmRoundTrip`
step (toggle PLC trigger → assert event surfaces via OPC UA AC client)
once the server-side wiring is in. Likely defers to a follow-up PR
after the server-tier alarm rendering catches up.
## Documentation, fixture, and e2e impact
Consolidated view across all 12 PRs. The **TCBSD fixture PLC project**
(`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/`) is
the **primary fixture-extension surface** — it's a real TwinCAT XAE project
committed object-by-object as `.TcGVL` / `.TcDUT` / `.TcPOU` files. Most PRs
extend it by adding GVL variables, DUTs (structs / enums / aliases), or POUs
(function blocks driving live data churn). The TCBSD VM at AmsNetId
`41.169.163.43.1.1` on `10.100.0.128` is the deployment target (per memory
entry `project_tcbsd_fixture.md`); the project bypasses the local Hyper-V/RTIME
conflict (per `project_twincat_hyperv_conflict.md`) by running on ESXi.
### User docs touched
| PR | `docs/Driver.TwinCAT.Cli.md` | `docs/drivers/TwinCAT-Test-Fixture.md` | `docs/v3/twincat-backlog.md` | Other |
|---|---|---|---|---|
| 1.1 LINT/ULINT | Data-types caveat removed | Bugs-caught entry #4 | — | — |
| 1.2 TIME/DATE/DT/TOD | Native-type syntax + 4 examples | — | — | — |
| 1.3 Bit-write | `write` example + RMW note | Bugs-caught entry #3 update | — | — |
| 1.4 Arrays | New "Arrays" sub-section + read example | Coverage list bullet | — | — |
| 1.5 ENUM/ALIAS | `browse` data-types rows | Coverage list bullet | — | — |
| 2.1 Sum cmds | — | New "Performance" section | Closed-out perf bullet | — |
| 2.2 Handles | Cache note in `read` / `subscribe` | Perf-section paragraph | — | — |
| 2.3 Sym-version | — | Online-change-handling caveat dropped | — | — |
| 3.1 MaxDelay | `--max-delay-ms` flag | Coalescing caveat updated | — | — |
| 3.2 Diagnostics | `probe` health-symbols sub-section | New "Diagnostics" section | Cycle-time bullet closed | — |
| 4.1 UDT | New top-level "UDT decomposition" section | Coverage list per-member | UDT-decomp gap removed | — |
| 5.1 Alarms | New `alarms` subcommand | "Alarms" caveat removed | — | **New** `docs/drivers/TwinCAT.md`; **new** `docs/v3/twincat-eventlogger-spike.md` |
### TCBSD fixture PLC project changes
| PR | GVL changes | DUT changes | POU changes | README section |
|---|---|---|---|---|
| 1.1 LINT/ULINT | `GVL_Primitives.vLargeCounter`, `vLargeCounterU` | — | — | "GVL_Primitives numeric seeds" |
| 1.2 TIME/DATE/DT/TOD | `GVL_Primitives.dCurrentTime`, `tCycleDuration`, `dToday`, `tShiftStart` | — | — | "Type coverage" seed values |
| 1.3 Bit-write | _(reuse `GVL_Primitives.vWord`)_ | — | — | — |
| 1.4 Arrays | `GVL_Arrays.aReal2D : ARRAY[1..5,1..5] OF REAL` | — | — | "Array coverage" |
| 1.5 ENUM/ALIAS | _(reuse `GVL_Enums`; new `currentSeverity`/`currentTemperature` instance vars)_ | — | — | "Integration-test contract" entry |
| 2.1 Sum cmds | **`GVL_Perf.aTags : ARRAY[1..1000] OF DINT`** | — | New `FB_PerfChurn` driving rotating writes | New "Performance scenarios" subsection |
| 2.2 Handles | _(reuse `GVL_Perf.aTags`)_ | — | — | — |
| 2.3 Sym-version | _(no schema change; manual online-change drill)_ | — | — | New "Online-change test scenario" |
| 3.1 MaxDelay | _(reuse `GVL_Fixture.nCounter` 100 Hz driver)_ | — | — | One-line note in "Required project state" |
| 3.2 Diagnostics | _(reads system GVLs `_AppInfo`, `_TaskInfo[1]`)_ | — | — | Probe-symbols callout |
| 4.1 UDT | _(reuse `GVL_Plant`; possibly grow `aLargeAlarms : ARRAY[1..2000] OF ST_AlarmRecord`)_ | New `ST_NestedFlags`, `ST_RecursiveCap`, `ST_AlarmRecord` | — | "Complex hierarchy" edge-cases |
| 5.1 Alarms | New `GVL_Alarms` (trigger booleans) | — | New `FB_AlarmHarness` calling `FB_TcLogEvent` | New "Alarm scenarios" |
### Integration test additions
All new tests gate on `[TwinCATFact]` / `[TwinCATTheory]` against
`TWINCAT_TARGET_NETID`. Most ship in `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.
IntegrationTests/TwinCAT3SmokeTests.cs`; PR 5.1 introduces a new
`TwinCATAlarmIntegrationTests.cs`. The existing 30-case suite grows to
roughly **45 cases** end-of-plan, plus a perf-tier guarded behind
`TWINCAT_PERF=1`.
### E2E scripts
`scripts/e2e/test-twincat.ps1` is the single TwinCAT e2e bridge today; it's
gated behind `TWINCAT_TRUST_WIRE=1` (see task #221 — CI fixture). The plan
intentionally **does not change** the canonical bridge for most PRs because
the bridge exercises one DINT counter through the OPC UA server, and that
path stays correct. PRs 1.2 (DT bridge), 1.4 (array bridge), 4.1 (UDT
bridge), 5.1 (alarm round-trip) each list speculative e2e extensions but
they're explicitly marked as follow-ups gated on server-side rendering
catching up.
## Skip-rated items (for context)
These are intentionally not built. Listed for future-reader completeness so
nobody re-invests effort that was already triaged:
| # | Gap | Why skip |
|---|---|---|
| 9 | Multi-target / multi-route AMS gateway | Per-device config in `TwinCATDriverOptions.Devices` already supports N targets |
| 10 | Secure ADS / ADS-over-TLS | Significant work — TC3.1 build 4024+ feature, host-router-level config; defer |
| 11 | Route credential management | Host-level AMS router responsibility (`StaticRoutes.xml`); not driver scope |
| 12 | NC-axis / CNC channel / EtherCAT slave I/O | Specialty; system-symbol filter actively drops `Mc_*` (`TwinCATSystemSymbolFilter.cs:28`) |
| 13 | System-service ports (200/10000) | Niche operational tooling; user-runtime ports cover real use cases |
| 15 | PLC RPC / method invocation | Niche; design-heavy; no demand signal yet |
| 16 | Per-PLC-runtime auto-discover | Cosmetic; manual port config in options works |
| 20 | File-system access via ADS (FOPEN/FREAD) | Niche; out of scope |
## Open questions
1. **(a) TMC parsing — separate library or embedded?**
Phase 4 ships the **online type-walker** path which uses
`Beckhoff.TwinCAT.Ads.TypeSystem.SymbolLoaderFactory` and needs a live
runtime. If a future use case needs offline discovery (e.g. address-space
pre-bake at build time without a reachable PLC), do we:
- vendor a TMC-XML parser into this driver, or
- build a separate `ZB.MOM.WW.OtOpcUa.Tooling.TwinCAT` CLI that emits a
pre-baked tag manifest?
The latter cleanly separates build-time tooling from runtime driver code
and matches how Galaxy.Host is split. Decision deferred until demand
appears; recommend the CLI route when it does.
2. **(b) Beckhoff TC3 EventLogger NuGet — published, or AMS port 110 raw?**
Need to spike against the current `Beckhoff.TwinCAT.Ads` v6 NuGet API
surface. Beckhoff InfoSys lists a `Tc3_EventLogger` PLC library and a
TcCOM C++ API but the .NET surface is thinner. PR 5.1 starts with a
one-day spike documented as `docs/v3/twincat-eventlogger-spike.md` before
committing to the implementation path.
3. **(c) Symbol-version invalidation event details**
PR 2.3 needs the exact index-group constant and notification semantics for
the symbol-version counter. `AdsReservedIndexGroup.SymbolVersion` (0xF008)
is the working hypothesis but the field on the v6 enum needs verification
— the older `TwinCAT.Ads.AdsReservedIndexGroup` enum had different naming.
Beckhoff InfoSys `tcadscommon/tcadscommon_indexgroups` is the reference;
confirm during the PR 2.3 spike. Fallback: poll the version counter at
probe-loop cadence and treat any change as an invalidation.
## References
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs`
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs`
- `docs/featuregaps.md` — TwinCAT (Beckhoff ADS) section
- `docs/v3/twincat-backlog.md` — deferred items (TC2, multi-hop, lab IPC)
- `docs/drivers/TwinCAT-Test-Fixture.md` — TCBSD + XAR fixture details
- Beckhoff InfoSys: <https://infosys.beckhoff.com/english.php?content=../content/1033/tcadsdll2/117571083.html> (Sum commands)
- Beckhoff InfoSys: <https://infosys.beckhoff.com/english.php?content=../content/1033/tcadsnetref/7313319051.html> (NotificationSettings)
- Beckhoff GitHub: <https://github.com/Beckhoff/TC3-AdsClient-Csharp>