Compare commits

...

1 Commits

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

Two distinct buckets:

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:28:01 -04:00
33 changed files with 8074 additions and 14 deletions

623
docs/featuregaps.md Normal file
View File

@@ -0,0 +1,623 @@
# Driver Feature Gaps vs Commercial OPC/SCADA Gateways
This document compares each non-Modbus, non-LMX driver in the OtOpcUa server against the feature surfaces of the dominant commercial gateways (Kepware KEPServerEX / PTC Kepware Edge, AVEVA OI Server / DAServer, Software Toolbox TOP Server, Matrikon, Unified Automation UaGateway, MTConnect-class Fanuc adapters, Beckhoff TF6100, etc.).
The intent is to:
- inventory what we already ship (with file:line citations into the current codebase)
- list missing or under-served features that are table-stakes for sites replacing those commercial gateways
- preserve the design choices that should NOT change just because a competitor does it differently
LMX (Galaxy / MXAccess) and Modbus are tracked elsewhere and are excluded here.
## Drivers covered
| Driver | Section | Implementation plan |
|---|---|---|
| AbCip — Allen-Bradley EtherNet/IP (ControlLogix / CompactLogix / Micro800 / GuardLogix) | [](#abcip-allen-bradley-ethernetip--logix) | [`plans/abcip-plan.md`](plans/abcip-plan.md) |
| AbLegacy — Allen-Bradley PLC-5 / SLC / MicroLogix (PCCC) | [](#ablegacy-allen-bradley-plc-5--slc--micrologix) | [`plans/ablegacy-plan.md`](plans/ablegacy-plan.md) |
| FOCAS — Fanuc CNC FOCAS / FOCAS2 | [](#focas-fanuc-cnc) | [`plans/focas-plan.md`](plans/focas-plan.md) |
| OpcUaClient — OPC UA aggregation client | [](#opcuaclient-opc-ua-aggregation-client) | [`plans/opcuaclient-plan.md`](plans/opcuaclient-plan.md) |
| S7 — Siemens S7-300 / 400 / 1200 / 1500 | [](#s7-siemens-s7-3004001200--1500) | [`plans/s7-plan.md`](plans/s7-plan.md) |
| TwinCAT — Beckhoff TwinCAT 2 / 3 (ADS) | [](#twincat-beckhoff-ads) | [`plans/twincat-plan.md`](plans/twincat-plan.md) |
## How to read this document
Every gap below is rated **[Build]** (recommended) or **[Skip]** (not recommended) inline at the start of the bullet. The same rating appears in the per-driver `### Recommendations` table with its rationale. The per-driver implementation plan in `docs/plans/` covers the **[Build]** items only.
---
## AbCip (Allen-Bradley EtherNet/IP — Logix)
### What we ship today
- Per-device `ab://gateway[:port]/cip-path` host-address with multi-hop CIP path via a comma-separated string (e.g. `1,2,2,192.168.50.20,1,0`) — `src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHostAddress.cs:23`.
- Four PLC-family profiles (`ControlLogix`, `CompactLogix`, `Micro800`, `GuardLogix`) selecting libplctag plc attribute, ConnectionSize default (504/4002/488), default CIP path (`1,0` or empty), connected-vs-unconnected hint, request-packing flag, and MaxFragmentBytes — `src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs:13-62`.
- N devices per driver instance with per-device bulkhead/breaker keying — `src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs:19`.
- Pre-declared static tag map (`AbCipTagDefinition`) keyed by `Name`, with `TagPath`, `DataType`, `Writable`, `WriteIdempotent`, `Members`, `SafetyTag``AbCipDriverOptions.cs:95-103`.
- Logix atomic types `BOOL/SINT/INT/DINT/LINT/USINT/UINT/UDINT/ULINT/REAL/LREAL/STRING/DT` plus `Structure` marker — `src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs:16-37`.
- Optional online controller browse via libplctag `@tags` pseudo-tag, surfaced under a `Discovered/` sub-folder; controller- and program-scope (`Program:Main.X`) tags emitted; system/module/routine/task tags filtered — `AbCipDriver.cs:674-757`, `AbCipSystemTagFilter.cs`.
- UDT / Predefined-Structure handling: declaration-driven member fan-out (Variable per member) plus runtime CIP Template Object (class 0x6C) decoder + per-device `(deviceHostAddress, templateInstanceId)` template cache — `CipTemplateObjectDecoder.cs`, `AbCipTemplateCache.cs`, `AbCipDriver.cs:70-103`.
- Whole-UDT read coalescing — `AbCipUdtReadPlanner` groups members of the same parent and reads the parent once, decoding members from the buffer at computed byte offsets — `AbCipDriver.cs:323-449`, `AbCipUdtReadPlanner.cs`, `AbCipUdtMemberLayout.cs`.
- BOOL-in-DINT addressing (`Tag.N` bit-index) with read-decode + RMW write through a per-parent `SemaphoreSlim` and cached parent-DINT runtime — `AbCipDriver.cs:494-614`, `AbCipTagPath.cs`.
- Polling subscription overlay shared with other drivers (`PollGroupEngine`) — `AbCipDriver.cs:56-59,187-195`.
- Per-device connectivity probe with configurable interval/timeout/probe tag (default off until tag configured) and `OnHostStatusChanged` events — `AbCipDriverOptions.cs:131-143`, `AbCipDriver.cs:235-295`.
- ALMD alarm projection (opt-in) polling `InFaulted` + `Severity`, raising `OnAlarmEvent` on edges, with ack-write — `AbCipAlarmProjection.cs`, `AbCipDriverOptions.cs:42-58`.
- GuardLogix safety-tag flag forces `SecurityClassification.ViewOnly``AbCipDriverOptions.cs:89-94`, `AbCipDriver.cs:474-478`.
- libplctag-status → OPC UA StatusCode mapping (`BadCommunicationError`, `BadNotWritable`, `BadTypeMismatch`, `BadOutOfRange`, `BadNodeIdUnknown`) — `AbCipStatusMapper.cs`.
- Tier-B reinit (`ReinitializeAsync`) tearing down all `IAbCipTagRuntime` handles — `AbCipDriver.cs:163-167`.
- CLI test client: `probe`, `read`, `write`, `subscribe` against the same driver — `docs/Driver.AbCip.Cli.md`.
### Gaps vs commercial gateways
- **[Build]** **Offline tag import from L5K / L5X** — present in: both (Kepware Logix Database Settings; TOP Server Auto Tag Generation). Why it matters: lets engineers stage a project against a Studio 5000 export with no PLC online, the de-facto config workflow at Rockwell shops.
- **[Build]** **CSV tag import / export** — present in: both. Why it matters: Kepware/AVEVA users routinely round-trip tag lists through Excel; replacing them without CSV makes mass-config painful.
- **[Build]** **Tag descriptions / engineering metadata** — present in: both (descriptions imported with L5X). Why it matters: descriptions become the OPC UA `Description`/`DisplayName`, expected by HMI/Historian engineers.
- **[Build]** **Logical-blocking / logical-non-blocking protocol modes** — present in: both (TOP Server names them; Kepware exposes equivalent "Optimize for read" / structure-block reads). Why it matters: whole-UDT vs per-member read strategy is the single biggest performance lever; we have one-direction whole-UDT only via `AbCipUdtReadPlanner`, no structure-block read for non-grouped members.
- **[Build]** **Symbolic vs logical (instance-ID) addressing toggle** — present in: both. Why it matters: logical addressing skips ASCII parsing on every poll, ~3-5x faster for high-tag-count rigs; libplctag supports it but we don't expose the choice.
- **[Build]** **Configurable CIP Connection Size per device** — present in: both (Kepware 500-4000 byte slider, TOP Server "Max Packet Size"). Why it matters: we hard-code the family default (4002/504/488); no field knob to tune for switches that fragment large frames or for legacy v19 firmware that won't accept Large Forward Open.
- **[Skip]** **Inactivity timeout / connection idle disconnect** — present in: both. Why it matters: long-idle CIP sessions get reaped silently by some firewalls; commercial drivers expose a keep-alive cadence we don't.
- **[Build]** **Per-tag scan rate / scan group bucketing** — present in: both (Kepware "scan classes", AVEVA Topic update intervals). Why it matters: lets engineers separate fast 100ms machine-state tags from 5s recipe data; we have one publishing-interval-per-subscription with no per-tag override.
- **[Skip]** **"Respect tag-specified scan rate" mode** — present in: Kepware. Why it matters: lets the static tag table override client-requested rate, important when an HMI subscribes too fast and overruns the PLC.
- **[Skip]** **Initial value cache / "first updates from cache"** — present in: Kepware. Why it matters: avoids a stall while a fresh subscription waits for its first poll; common SCADA expectation.
- **[Build]** **Multi-tag write packing (write-multi)** — present in: both. Why it matters: we serialise writes one-by-one in `AbCipDriver.WriteAsync`; without CIP multi-request packing for writes a recipe-download is N round-trips instead of one.
- **[Build]** **AOI (Add-On Instruction) input/output handling** — present in: Kepware (with explicit InOut limitation note). Why it matters: AOIs are how modern Logix code is structured; the Template Object decoder probably handles the layout but we don't surface AOI-specific browse paths.
- **[Build]** **Native STRING (Logix STRING / custom STRINGxx) decoding** — present in: both (Kepware preserves descriptors; AVEVA exposes as native string). Why it matters: we map Logix `STRING` to `DriverDataType.String` but `AbCipDataType.cs` flags whole-string only; no support for user-defined `STRINGnn` variants of different DATA-array sizes.
- **[Build]** **64-bit integer surface (LINT/ULINT)** — present in: both. Why it matters: Logix v32+ exposes LINT for 64-bit counters/timestamps; we widen them into `Int32` per a TODO at `AbCipDataType.cs:53`, losing the upper bits.
- **[Skip]** **Structure / UDT as first-class OPC UA structured type** — present in: both (Kepware emits child tags; AVEVA exposes via native UDT). Why it matters: we emit `DriverDataType.String` placeholder for whole-UDT, only members are fully typed; OPC UA clients can't bind to a UDT shape.
- **[Build]** **Array element / array slice addressing** — present in: both (Kepware `Tag[3,5]`, slice `Tag[0..15]`). Why it matters: `AbCipTagPath` supports indexed elements but the driver has no array-slice read for adjacent indices; reading `Tag[0..99]` becomes 100 individual reads.
- **[Skip]** **PLC-5 / SLC-500 bridging via ControlLogix gateway** — present in: both (Kepware Logix Gateway, TOP Server NET-ENI). Why it matters: thousands of legacy AB sites front a PLC-5/SLC behind a 1756-ENBT; without the bridge those plants can't migrate to us in one step.
- **[Build]** **Hot-standby ControlLogix redundancy (paired EN2T IPs)** — present in: AVEVA (and Kepware via secondary device). Why it matters: ControlLogix HSBY pairs are standard in continuous-process plants; today our driver has one host address per device, no automatic failover to the partner chassis.
- **[Build]** **Diagnostics / system tags (`_ConnectionStatus`, `_ScanRate`, `_TagCount`, `_DeviceError`)** — present in: both. Why it matters: SCADA dashboards bind to these for live driver health; we expose `IHostConnectivityProbe` + `DriverHealth` but not as browseable OPC UA variables.
- **[Build]** **Tag-write deadband / write-on-change / write-coalesce** — present in: both. Why it matters: avoids hammering the PLC on jittery analogue setpoints; we write every request straight through.
- **[Skip]** **Unsolicited messages (PLC-pushed CIP MSG)** — present in: AVEVA (DASABCIP unsolicited topic), Kepware (separate "ControlLogix Unsolicited" driver). Why it matters: event-driven alarm/recipe-complete signals from the PLC arrive with sub-100ms latency vs our 1s alarm-poll loop.
- **[Skip]** **CIP Generic / Class 3 message passthrough** — present in: both. Why it matters: enables custom tooling (drive parameters, motion config, MSG instruction targets) for shops that have built around it.
- **[Skip]** **Configurable per-device connection count / connection pooling** — present in: both (AVEVA: max 31). Why it matters: lets operators trade PLC CPU cost against parallelism for high-throughput rigs; we run one connection per tag handle implicitly.
- **[Build]** **Online tag-database refresh trigger** — present in: AVEVA (`$Sys$UpdateTagInfo`). Why it matters: lets ops force re-browse after a Studio 5000 download without restarting the driver; we only re-browse on full driver reinit.
### Recommendations
| # | Gap | Build? | Rationale |
|---|-----|:------:|-----------|
| 1 | Offline L5K / L5X import | Yes | De-facto Studio 5000 workflow; engineers won't switch without it |
| 2 | CSV tag import / export | Yes | Common round-trip via Excel for mass config |
| 3 | Tag descriptions / engineering metadata | Yes | Free once L5X import lands; expected as OPC UA `Description` |
| 4 | Logical-blocking / non-blocking modes | Yes | Biggest perf lever; today only whole-UDT coalescing |
| 5 | Symbolic vs logical (instance-ID) toggle | Yes | 3-5x perf on dense rigs; libplctag already supports it |
| 6 | Configurable Connection Size per device | Yes | Cheap field knob for v19 firmware / fragmenting switches |
| 7 | Inactivity timeout / keep-alive cadence | No | Rarely an issue with libplctag-managed connections |
| 8 | Per-tag scan rate / scan groups | Yes | Standard SCADA expectation; mixed-rate tag tables |
| 9 | "Respect tag-specified scan rate" mode | No | Niche; OPC UA subscription rate already covers it |
| 10 | Initial value cache / first-update from cache | No | OPC UA subscription sampling already handles first-update |
| 11 | Multi-tag write packing | Yes | Recipe-download speed; one PDU vs N |
| 12 | AOI input / output handling | Yes | Standard modern Logix code structure |
| 13 | Native STRING / STRINGnn decoding | Yes | Table-stakes; we passthrough as String only |
| 14 | 64-bit LINT / ULINT fidelity | Yes | Correctness on Logix v32+; we silently truncate (TODO in code) |
| 15 | UDT as first-class OPC UA structured type | No | Member fan-out already works; structured-type plumbing is heavy |
| 16 | Array slice addressing `Tag[0..15]` | Yes | Perf; reads of N-element arrays in one call |
| 17 | PLC-5 / SLC bridging through CLX | No | AbLegacy driver covers this protocol family |
| 18 | Hot-standby ControlLogix redundancy | Yes | Continuous-process plants standardize on HSBY pairs |
| 19 | Diagnostic system tags (`_ConnectionStatus` etc.) | Yes | HMI dashboards bind to them; cheap given DriverHealth |
| 20 | Write deadband / write-on-change | Yes | Analog setpoints flood the PLC without it |
| 21 | Unsolicited CIP MSG ingestion | No | Separate driver in commercial; design-heavy; niche |
| 22 | CIP Generic / Class 3 passthrough | No | Niche custom-tooling territory |
| 23 | Per-device connection count / pooling | No | libplctag manages connections; premature |
| 24 | Online tag-DB refresh trigger | Yes | Cheap; avoids restart after PLC download |
### Notable parity (keep)
- libplctag-class wire layer covering ControlLogix/CompactLogix/Micro800/GuardLogix on EtherNet/IP CIP — same controller coverage as the commercial drivers (minus PLC-5/SLC).
- Multi-hop CIP path syntax with bridge-through chassis (`1,2,2,IP,1,0` form) — matches Kepware/AVEVA routing semantics.
- Online controller browse with program-scope vs controller-scope distinction and system-tag filtering — same shape as Kepware Auto Tag Generation.
- CIP Template Object (class 0x6C) decoder for live UDT-shape resolution + cache — feature-parity with Kepware's structure-aware Auto Tag Generation.
- Whole-UDT read coalescing for grouped members — matches TOP Server "logical blocking" optimisation for the cases it covers.
- BOOL-in-DINT bit-index addressing with RMW serialisation per parent — same semantics commercial drivers expose for `Tag.N` bit access.
- Per-PLC-family Connection Size / connected-messaging / fragment-bytes profile — mirrors the per-controller "model" picker in Kepware.
- ALMD alarm projection with edge-detected raise/clear — reasonable parity for the alarm subset of FT Alarms & Events that those drivers do not natively translate.
- Per-device circuit-breaker / bulkhead isolation keyed on `(driver, hostName)` — better operational story than the typical commercial gateway, which trips the whole channel on one bad device.
- GuardLogix safety-tag write rejection at config time — explicit, matches Rockwell's safety-partition rules.
### Sources
- [Kepware Allen-Bradley ControlLogix Ethernet driver overview](https://support.ptc.com/help/kepware/drivers/en/kepware/drivers/CONTROLLOGIXETHERNET/Overview.html)
- [Kepware Logix Database Settings (offline / online ATG, L5K/L5X)](https://support.ptc.com/help/kepware/drivers/en/kepware/drivers/CONTROLLOGIXETHERNET/Logix_Database_Settings.html)
- [Kepware Preparing for Automatic Tag Database Generation](https://support.ptc.com/help/kepware/drivers/en/kepware/drivers/CONTROLLOGIXETHERNET/Preparing_for_Automatic_Tag_Database_Generation.html)
- [Kepware Device Properties — Scan Mode (respect tag-specified, demand poll, initial cache)](https://support.ptc.com/help/kepware/drivers/en/kepware/drivers/Device_Properties_Scan_Mode.html)
- [Kepware Allen-Bradley ControlLogix Ethernet driver manual (PDF, 2025)](https://downloads.softwaretoolbox.com/demodnld/prod_docs/topserver_help_pdf/Common/allen-bradley-controllogix-ethernet-manual.pdf)
- [Kepware Allen-Bradley ControlLogix Server (Unsolicited)](https://www.ptc.com/en/store/kepware/drivers/allen-bradley-controllogix-unsolicited)
- [Kepware System Tags](https://support.ptc.com/help/kepware/kepware_edge/en/kepware/kepware-edge/system-tags.html)
- [TOP Server ControlLogix protocol modes (symbolic / logical-blocking / logical-non-blocking)](https://blog.softwaretoolbox.com/optimizing-controllogix-protocol-modes)
- [TOP Server Rockwell ControlLogix Ethernet OPC driver details](https://softwaretoolbox.com/top-server/rockwell-ab-controllogix-ethernet)
- [TOP Server ControlLogix Ethernet performance optimization](https://softwaretoolbox.com/top-server/rockwell-ab-controllogix-performance)
- [Software Toolbox FAQ — making configuration choices for ControlLogix Ethernet](https://help.softwaretoolbox.com/faq/1658)
- [AVEVA Communication Drivers Pack — ABCIP Driver user guide (PDF)](https://cdn.logic-control.com/docs/aveva/communications-pack/OIABCIP.pdf)
- [Wonderware DASABCIP user guide (PDF)](https://cdn.logic-control.com/media/DASABCIP.pdf)
- [Wonderware OI.ABCIP server user guide (PDF, v7.0)](https://s3-us-west-2.amazonaws.com/wonderwarepacwest/downloads/oi-abcip-user-guide.pdf)
- [Industrial Software Solutions — DASABCIP unsolicited message handling](https://industrial-software.com/training-support/tech-notes/74-how-configure-wonderware-dasabcip-unsolicited-message-handling/)
- [Industrial Software Solutions — `$Sys$UpdateTagInfo` with ABCIP](https://industrial-software.com/training-support/tech-notes/119-using-sysupdatetaginfo-with-abcip-oi-servers/)
- [AVEVA — Configure the ABCIP Communication Driver](https://docs.aveva.com/bundle/sp-cdp-drivers/page/193749.html)
---
## AbLegacy (Allen-Bradley PLC-5 / SLC / MicroLogix)
### What we ship today
- Per-device family knob: `Slc500` / `MicroLogix` / `Plc5` / `LogixPccc`, each mapped to a libplctag PLC attribute, default CIP path, max-tag-bytes, and string/long-file capability flags (`PlcFamilies/AbLegacyPlcFamilyProfile.cs:14-54`).
- Single transport: PCCC encapsulated in EtherNet/IP via libplctag, with `ab://gateway[:port]/cip-path` host strings supporting CLX-bridged routing (`AbLegacyHostAddress.cs:14-52`).
- File-letter set: `N`, `F`, `B`, `L`, `ST`, `T`, `C`, `R`, `I`, `O`, `S`, `A` parsed and validated; trailing `/N` bit index and `.SUBELEMENT` (ACC/PRE/EN/DN/TT/CU/CD/LEN/POS/ER) recognised (`AbLegacyAddress.cs:97-101`, `AbLegacyDataType.cs:9-29`).
- Data types: `Bit`, `Int` (N/A), `Long` (L), `Float` (F), `String` (ST), `TimerElement`, `CounterElement`, `ControlElement` — all surfacing as `Boolean` / `Int32` / `Float32` / `String` driver types (`AbLegacyDataType.cs:34-44`).
- Bit-within-N-word write path: read-modify-write against a parent-word runtime, serialised by per-parent `SemaphoreSlim` (`AbLegacyDriver.cs:353-409`).
- Polling overlay via shared `PollGroupEngine` exposed through `ISubscribable`; per-publishing-interval grouping (`AbLegacyDriver.cs:268-276`).
- Connectivity probe loop per device (default `S:0`, configurable interval/timeout) emitting `HostStatusChangedEventArgs` transitions (`AbLegacyDriver.cs:283-336`, `AbLegacyDriverOptions.cs:36-44`).
- Capability surfaces: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IPerCallHostResolver` — flat `AbLegacy/<host>/<tag>` browse tree built from static config (`AbLegacyDriver.cs:11-12`, `238-264`).
- Static-config tag list only (`AbLegacyTagDefinition`); writes can be flagged `Writable=false` and `WriteIdempotent=true` (`AbLegacyDriverOptions.cs:28-34`).
- Status mapping for libplctag error codes to OPC UA StatusCodes (`AbLegacyStatusMapper.cs`).
### Gaps vs commercial gateways
- **[Skip]** **Serial DF1 transports (full-duplex, half-duplex master/slave, KF2/KF3, radio modem)** — present in: both. Why: libplctag PCCC is Ethernet-only; no COM-port path means PLC-5/SLC/ML serial deployments are unreachable.
- **[Build]** **DH+ via 1756-DHRIO / 1784-PKTX gateway routing** — present in: both. Why: DH+ Gateway is the canonical way to reach PLC-5 nodes through a CLX rack today; we expose a CIP path but no station-number addressing or DH+ link-id concept.
- **[Skip]** **DH-485 routing through 1761-NET-AIC / 1747-AIC** — present in: both. Why: MicroLogix 1000/1200 and SLC 5/03 multi-drop deployments need DH-485 station addressing.
- **[Skip]** **`M0` / `M1` module file access (block-transfer / RIO data)** — present in: Kepware, AVEVA. Why: Required for any PLC-5 with RIO modules or specialty cards (motion, weigh, vision); PCCC has dedicated frames.
- **[Build]** **`PD` (PID), `MG` (Message), `PLS` (programmable limit switch), `BT` (block transfer) function/structure files** — present in: both. Why: Standard SLC/PLC-5 file types for PID loops and message instructions; we cap at T/C/R structures only.
- **[Skip]** **`D` (BCD) and Long-BCD types** — present in: both. Why: Some legacy SLC/PLC-5 programs store recipe / setpoint data as packed BCD; we only ship binary `Int`/`Long`.
- **[Build]** **PLC-5 octal addressing for I/O word/bit (`I:001/17`)** — present in: both. Why: Native PLC-5 documentation and RSLogix 5 use octal; rejecting decimal-only addresses misreads real configs.
- **[Build]** **Indirect / indexed addressing (`N7:[N7:0]`, `N[N7:0]:5`)** — present in: both. Why: Common pattern for recipe / batch lookup tables; libplctag supports it but our parser only accepts literal `<letter><file>:<word>`.
- **[Build]** **Array reads / contiguous block addressing (`N7:0,10` or `N7:0[10]`)** — present in: both. Why: One PCCC request can pull up to ~120 words; absent array syntax forces N round-trips for 1-of-N tags and breaks block-read sizing optimisation.
- **[Build]** **String-file (`ST`) read/write path in production** — present in: both. Why: Type is enum-listed but `AbLegacyDataTypeExtensions.ToDriverDataType` maps to `String` only; ST is an 82-byte fixed buffer with a length word and we have no integration coverage to confirm round-trip.
- **[Build]** **Sub-element predefined symbol coverage (timer `.PRE/.ACC/.EN/.TT/.DN`, counter `.CU/.CD/.OV/.UN`, control `.LEN/.POS/.ER/.UL/.IN/.FD`)** — present in: both. Why: Parser admits any all-letters sub-element but the `TimerElement/CounterElement/ControlElement` types collapse to a single `Int32`, losing per-bit Boolean semantics that HMIs expect (`.DN` should be Bit, not Int32).
- **[Skip]** **Block read-size negotiation per family** — present in: both. Why: We carry `MaxTagBytes` as a constant but never plumb it into a request optimiser; libplctag's PCCC chunking is implicit and not tunable per-tag-group.
- **[Build]** **Auto-demote on comm failure** — present in: both. Why: Kepware/TOP Server temporarily off-scan a non-responsive device for N seconds so other devices on the channel keep flowing; we only switch a `HostState` flag and keep retrying.
- **[Skip]** **Communication serialisation across multiple devices on one channel** — present in: both. Why: DH+/DF1 networks share a single physical link; we have no channel concept, so a slow PLC-5 can starve a fast SLC on the same DH+ link.
- **[Build]** **RSLogix 500 (`.RSS`) / RSLogix 5 (`.RSP`) / `.SLC` symbol & data-table import for automatic tag generation** — present in: both (DF1, AB Ethernet drivers). Why: Manual `AbLegacyTagDefinition` entries scale poorly; commercial tools parse RSLogix exports to seed tags and descriptions.
- **[Skip]** **Online browse / data-table discovery from the controller** — present in: Kepware (Create-from-Device). Why: PCCC has a "read file directory" frame; we don't issue it, so `DiscoverAsync` only ever returns the static config.
- **[Skip]** **DF1 error checking selection (BCC vs CRC-16)** — present in: both. Why: Some serial gear (older modems) only does BCC; not applicable until serial transport ships, but flagged for parity.
- **[Build]** **Per-tag deadband / change filter on subscriptions** — present in: both. Why: Polling overlay publishes every poll; commercial drivers suppress no-op publishes by absolute-deadband or scaling.
- **[Skip]** **PLC-5 typed-write / typed-read selection vs SLC protected typed reads** — present in: both. Why: Kepware exposes "Optimization Method" and "Force Logical=Yes" knobs that materially affect performance on slower processors; we use libplctag defaults silently.
- **[Build]** **Diagnostic counters (request count, response time, retries, last-error per device, comm-failures)** — present in: both (built-in `_System` / `_DiagnosticTags`). Why: We surface a `DriverHealth` enum but no per-device tag-level diagnostics for an HMI to bind to.
- **[Build]** **Per-device timeout / retry overrides** — present in: both. Why: We have one driver-wide `Timeout` (`AbLegacyDriverOptions.cs:16`) and one probe timeout; SLC 5/01 vs SLC 5/05 vs MicroLogix 1100 need very different values on a shared driver.
- **[Skip]** **Write completion semantics — synchronous-confirmation vs queued** — present in: both. Why: Commercial drivers offer "write optimization (latest value only / write-through / disable)"; ours always writes through, which floods slow channels with redundant writes.
- **[Build]** **MicroLogix-specific item naming (e.g. `RTC:0.HR`, `HSC:0`, `DLS:0` for daylight savings)** — present in: both. Why: MicroLogix 1100/1400 have proprietary function files that don't share file letters with SLC and our `IsKnownFileLetter` whitelist rejects them.
### Recommendations
| # | Gap | Build? | Rationale |
|---|-----|:------:|-----------|
| 1 | Serial DF1 transports | No | Declining install base; libplctag has no serial path; major scope |
| 2 | DH+ via 1756-DHRIO bridging | Yes | Real-world PLC-5 path; libplctag CIP routing already supports it |
| 3 | DH-485 routing (1761/1747-AIC) | No | Very legacy; rare in greenfield |
| 4 | M0 / M1 module file access | No | Niche RIO modules; declining |
| 5 | PD / MG / PLS / BT files | Yes | PID files are common in real SLC programs |
| 6 | D (BCD) and Long-BCD types | No | Very legacy data convention |
| 7 | PLC-5 octal addressing | Yes | Correctness for actual PLC-5 sites |
| 8 | Indirect / indexed addressing | Yes | Standard recipe / lookup pattern |
| 9 | Array contiguous block addressing | Yes | Big perf gain; one PCCC frame vs N |
| 10 | ST string read / write production verification | Yes | Type is enum-listed but untested; cheap to validate |
| 11 | Sub-element bit semantics (`.DN` as Bit, etc.) | Yes | Correctness; HMIs expect Boolean for `.DN`/`.EN`/`.TT` |
| 12 | Block read-size negotiation per family | No | libplctag handles chunking implicitly |
| 13 | Auto-demote on comm failure | Yes | Standard SCADA resilience; one slow PLC starves fast ones |
| 14 | Channel-shared comm serialisation | No | Only matters for serial / DH+ (transport not built) |
| 15 | RSLogix 500/5 (.RSS / .RSP) symbol import | Yes | Workflow parity; manual config doesn't scale |
| 16 | Online controller browse / data-table discovery | No | PCCC dir frame limited; libplctag support unclear |
| 17 | DF1 BCC vs CRC-16 selection | No | Predicated on DF1 transport (gap #1) |
| 18 | Per-tag deadband / change filter | Yes | Polling overlay floods every poll without it |
| 19 | PLC-5 typed-read selection / Force Logical | No | libplctag defaults are sound; niche tuning |
| 20 | Diagnostic counters as tags | Yes | HMI binding; cheap given existing health probe |
| 21 | Per-device timeout / retry overrides | Yes | SLC 5/01 vs 5/05 vs ML1100 differ; cheap |
| 22 | Write completion semantics options | No | Niche tuning; current write-through is safe default |
| 23 | MicroLogix function-file naming (RTC/HSC/DLS) | Yes | Correctness for ML1100/1400 deployments |
### Notable parity (keep)
- Family enum + per-family profile keeps SLC 500 / MicroLogix / PLC-5 / LogixPccc-mode behavioural differences explicit instead of probed at runtime (`PlcFamilies/AbLegacyPlcFamilyProfile.cs:14-54`).
- ControlLogix-bridged routing string (`ab://gw/1,0`) matches Kepware's "Routing Path" concept and is how real PLC-5 deployments are reached today (`AbLegacyHostAddress.cs:14-52`).
- Bit-within-N-word RMW with per-parent serialisation prevents the classic two-writer-tear bug other drivers ship (`AbLegacyDriver.cs:353-384`).
- Probe loop with explicit `HostState` transitions gives a cleaner diagnostic surface than Kepware's lump-sum auto-demote (`AbLegacyDriver.cs:283-336`).
- Status-file probe (`S:0`) is the same heartbeat Rockwell HMIs traditionally use, and it's family-agnostic (`AbLegacyDriverOptions.cs:43`).
- libplctag back-end inherits ongoing community fixes for PCCC frame edge-cases without us owning the wire decoder.
### Sources
- [Kepware Allen-Bradley Ethernet Driver Manual (PDF)](https://cdn.logic-control.com/docs/kepware/Manuals/Drivers/Allen-Bradley/Allen-Bradley%20Ethernet%20Driver.pdf)
- [Kepware Allen-Bradley DF1 Driver Manual (PDF)](https://cdn.logic-control.com/docs/kepware/Manuals/Drivers/Allen-Bradley/Allen-Bradley%20DF1%20Driver.pdf)
- [Kepware Allen-Bradley ControlLogix Ethernet Driver Manual (PDF, 2025)](https://downloads.softwaretoolbox.com/demodnld/prod_docs/topserver_help_pdf/Common/allen-bradley-controllogix-ethernet-manual.pdf)
- [Kepware Allen-Bradley ControlLogix Driver Manual (PDF, 2017)](https://ftp.softwaretoolbox.com/demodnld/prod_docs/topserver_help_pdf/v5_20/controllogix_ethernet.pdf)
- [Kepware Allen-Bradley Ethernet driver product page](https://www.kepware.com/en-us/products/kepserverex/drivers/allen-bradley-ethernet/)
- [TOP Server Rockwell DF1 Serial driver](https://softwaretoolbox.com/top-server/rockwell-ab-df1)
- [AVEVA Communication Drivers Pack 2023 R2 readme](https://www.wmkit.com/archives/aveva-communication-drivers-pack-2023-r2-readme.html)
- [AVEVA Communication Drivers Pack 2020 R2 readme](https://industrial-software.com/wp-content/uploads/Communication_Drivers/oi-communication-drivers-pack-2020-r2/Readme.html)
- [AVEVA Communication Drivers datasheet (PDF)](https://www.aveva.com/content/dam/aveva/documents/datasheets/Datasheet_AVEVA-CommunicationDrivers_11-19.pdf)
- [AVEVA OI ABCIP user guide (PDF)](https://s3-us-west-2.amazonaws.com/wonderwarepacwest/downloads/oi-abcip-user-guide.pdf)
- [Kepware Logix Database Settings (Create-from-Device / .L5K import)](https://support.ptc.com/help/kepware/drivers/en/kepware/drivers/CONTROLLOGIXETHERNET/Logix_Database_Settings.html)
- [Rockwell DF1 Protocol and Command Set reference (1770-RM516, PDF)](https://literature.rockwellautomation.com/idc/groups/literature/documents/rm/1770-rm516_-en-p.pdf)
---
## FOCAS (Fanuc CNC)
### What we ship today
- TCP-only Ethernet transport on port 8193 via the pure-managed `Focas.Wire` client; no Fwlib DLL, no P/Invoke, no out-of-process Tier-C host (`docs/drivers/FOCAS.md:8-13`, retired Host noted at `:25-27`).
- One driver instance can host N CNCs, each keyed by `focas://{ip}[:{port}]` (`FocasDriverOptions.cs:10`, `FocasDeviceOptions:92-95`).
- Per-device CNC series declaration (`Zero_i_D/F/MF/TF`, `Sixteen_i`, `Thirty_i`, `ThirtyOne_i`, `ThirtyTwo_i`, `PowerMotion_i`, `Unknown`) with init-time capability matrix validating macro / parameter / PMC ranges per series (`FocasCncSeries.cs:21-47`, `FocasCapabilityMatrix.cs:29-138`).
- User-authored tag addressing for: PMC bits/bytes (`X0.0`, `R100`, `R100.3`), CNC parameters (`PARAM:1815/0`), and macro variables (`MACRO:500`) — wired through `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` (`docs/drivers/FOCAS.md:62-66, 90`).
- Atomic data types: Bit, Byte, Int16, Int32, Float32, Float64, String (`FocasDataType.cs:10-26`).
- Read-only by design — `WriteAsync` returns `BadNotWritable`; no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` paths exist (`FocasDriver.cs:222-279`, `docs/drivers/FOCAS.md:17-18, 91`).
- Optional `FixedTree` auto-populated subtree per device (`FocasFixedTreeOptions:26-51`) populated at bootstrap from `cnc_sysinfo` + `cnc_rdaxisname` + `cnc_rdspdlname`, polled at three cadences (axis 250 ms, program 1 s, timer 30 s):
- `Identity/``SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` (`FocasDriver.cs:299-304`).
- `Axes/{name}/``AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` (cap-gated) (`FocasDriver.cs:307-316`).
- `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` (single-channel rates — first axis only, `FocasDriver.cs:317-318`, `:646-651`).
- `Spindle/{name}/Load`, `Spindle/{name}/MaxRpm` (cap-gated, multi-spindle aware) (`FocasDriver.cs:323-336`).
- `Program/Name`, `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` (`FocasDriver.cs:339-347`).
- `OperationMode/Mode` + `ModeText` ("MDI"/"AUTO"/"EDIT"/"HANDLE"/"JOG"/"TEACH_IN_HANDLE"/"REFERENCE"/"REMOTE"/"TEST"/"TJOG") (`IFocasClient.cs:213-226`).
- `Timers/PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` (`FocasDriver.cs:355-362`).
- Per-series node suppression: optional API probes at bootstrap, `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` causes the corresponding subtree to not be emitted (`docs/drivers/FOCAS.md:134-142`, `FocasDriver.cs:497-526`).
- Active-alarm projection via `IAlarmSource` (opt-in, polls `cnc_rdalmmsg2` at 2 s default), differential raise/clear with mapped alarm types `Parameter / PulseCode / Overtravel / Overheat / Servo / DataIo / MemoryCheck / MacroAlarm`, severity buckets, and ack as no-op (`FocasAlarmProjectionOptions:79-85`, `IFocasClient.cs:275-287`, `docs/drivers/FOCAS.md:154-181`).
- Connectivity probe via `cnc_rdcncstat` on configurable interval; transitions fire `OnHostStatusChanged` (`FocasProbeOptions:110-115`, `docs/drivers/FOCAS.md:94`).
- Optional proactive handle-recycle loop to release FWLIB session handles on a cadence (defends against the documented handle-leak bugs and finite ~510 connection pool) (`FocasHandleRecycleOptions:68-72`, `docs/drivers/FOCAS.md:184-205`).
- Subscriptions are emulated via the shared `PollGroupEngine` (FOCAS has no push) (`FocasDriver.cs:451-461`).
- `IPerCallHostResolver` so each tag's reads route to its declared device, enabling per-host bulkhead resilience (decision #144) (`FocasDriver.cs:850-857`, `FocasDriverOptions.cs:3-7`).
### Gaps vs commercial gateways / MTConnect adapters
- **[Build]** **Writes (parameters / PMC / macro)** — Kepware "Fanuc Focas HSSB and Ethernet Driver", Ignition Fanuc, Memex Merlin, Predator MDC. Why: Macro / PMC writes are the canonical mechanism for DPRNT-free supervisory feedback to ladder logic; we explicitly return `BadNotWritable`.
- **[Skip]** **HSSB (high-speed serial bus) transport** — Kepware, MTConnect Fanuc Adapter (Cincinnati), Memex. Why: HSSB is the only path on machines with no FOCAS Ethernet option licensed; we are TCP:8193 only, no `hssb` discovery, no PCI handle.
- **[Build]** **FOCAS password / unlock parameter** — Kepware ("Password" property), MTConnect adapter. Why: Some controllers gate `cnc_wrparam` and certain reads behind a connection-level password; we have no such property in `FocasDeviceOptions`.
- **[Build]** **Multi-path / multi-channel CNC support** — Kepware (Path number 1..n), MTConnect (per-path Components). Why: 30i/31i/32i can host 2-10 paths each with their own program / position / mode; our `cnc_setpath`-equivalent never runs and the fixed tree implicitly assumes path 1.
- **[Skip]** **Series 15, Series 15i, Power Mate D/H, Series 35i** — Kepware lists 15/15i, MTConnect adapter handles legacy. Why: Our `FocasCncSeries` enum stops at Power Motion i + 16i; legacy Series 15 deployments would either fail validation or be forced to `Unknown`.
- **[Build]** **`cnc_getfigure` decimal scaling** — Kepware, MTConnect, Memex. Why: Position values are exposed as raw scaled ints (Float64-typed) and we punt the divide-by-10^N onto the client; commercial gateways present pre-scaled millimeters/inches. (Acknowledged TODO in `docs/drivers/FOCAS.md:144-148`.)
- **[Build]** **G-code / modal info (`cnc_modal`)** — Kepware ModalCodes group, MTConnect (FunctionalMode, MotionMode, PlaneCode, etc.), Ignition. Why: Modal G/M-code state (G54 active, G90/91, G17/18/19, M03/04/05, S/F overrides) is one of the most-asked CNC tag groups; we have neither a fixed-tree exposure nor a `MODAL:` address scheme.
- **[Build]** **Tool number, current tool, tool life management** — Kepware (T-code, ToolLife group), MTConnect (`ToolNumber`, `ToolGroup`), Memex, Predator MDC. Why: Live `cnc_rdtlife*` / current T-code are core MES integration data; absent.
- **[Skip]** **Tool offset table read/write (`cnc_rdtofs` / `cnc_wrtofs`)** — Kepware, Ignition. Why: Tool length / wear / radius compensation tables are often supervisory-edited; we have no `TOFS:` address scheme.
- **[Build]** **Work coordinate offsets (G54..G59 + extended via `cnc_rdzofs` / `cnc_wrzofs`)** — Kepware "WorkOffsets" group, MTConnect (`PartCount` and `WorkCoordinate`). Why: Setup automation needs to read/poke work offsets; absent.
- **[Build]** **Override values (Feedrate %, Rapid %, Spindle %, Jog %)** — Kepware OverrideGroup, MTConnect (`PathFeedrateOverride`, `RotaryVelocityOverride`). Why: Operator-modulated speeds are crucial for OEE/MES; not in the dynamic snapshot.
- **[Build]** **Status / running flags surfaced as nodes (Auto, Run, Motion, Mstb, EmergencyStop, Edit, Tmmode, Alarm bool)** — MTConnect adapter exposes `Execution`, `ControllerMode`, `EmergencyStop` directly. Why: We poll `cnc_rdcncstat` only as a Boolean probe; the 9-field ODBST struct (tmmode/aut/run/motion/mstb/emergency/alarm/edit) is never projected to nodes.
- **[Build]** **Parts count / required parts (`cnc_rdparam` 6711/6712/6713)** — Kepware "PartCount", MTConnect `PartCountAct/Min/Max`. Why: Part counters are MES bread-and-butter; reachable today only by user-authored `PARAM:6711` tag, not in the fixed tree.
- **[Build]** **Diagnostic numbers (`cnc_rddiag` / `cnc_rddiagdgn`)** — Kepware Diagnostic group, MTConnect. Why: Servo/spindle diagnostics (axis position errors, current, temperature) are essential for predictive maintenance; no `DIAG:` address scheme.
- **[Build]** **PMC data ranges (D/T/C/K/F/G addresses) for Series 16i** — partially limited by our matrix (`PmcLetters(Sixteen_i)` only allows X/Y/R/D, `FocasCapabilityMatrix.cs:80`). Why: Real 16i ladders use F/G signals for handshakes; users would have to set Series=Unknown to bypass validation.
- **[Build]** **Bulk PMC range read (`pmc_rdpmcrng` multi-byte)** — Kepware coalesces consecutive PMC bytes; we issue one request per tag. Why: One TCP RTT per PMC byte at scale will saturate; commercial drivers batch into ranges of up to 1KB.
- **[Build]** **Alarm history (`cnc_rdalmhistry` / `cnc_rdalmhistry5`)** — MTConnect adapter, Memex. Why: Acked alarms persist in a CNC ring buffer; we surface only the active alarm list.
- **[Build]** **External operator messages (`cnc_rdopmsg` / `cnc_rdopmsg2` / `cnc_rdopmsg3`)** — Kepware OpMessage tag, MTConnect (`Message` data item). Why: Macro programmers display operator messages via #3006 / G65 P9099 etc.; not exposed.
- **[Skip]** **Program list / upload / download / delete (`cnc_rdprogdir` / `cnc_upstart` / `cnc_dnstart` family)** — Kepware program-management group, Predator MDC, Memex Merlin. Why: DNC drip-feed is a primary use case for MDC products; entirely absent.
- **[Build]** **Currently-executing program text (`cnc_rdactpt` / `cnc_rdexecprog`)** — Kepware "CurrentProgram", MTConnect `Block` and `Line`. Why: Live block display / current sequence content; we expose `Sequence` (number) but not the block text.
- **[Skip]** **DPRNT / external data input (`cnc_rdmacrohk` / external macro)** — Predator MDC, Forcam, Memex (DPRNT collector). Why: DPRNT is the standard 1980s-vintage CNC-to-MES messaging path; we have no DPRNT TCP listener and no macro-call subscription.
- **[Skip]** **Servo / spindle deep info (`cnc_rdsvinfo` / `cnc_rdspinfo`)** — Kepware, Memex. Why: Servo cycle counts, spindle motor speed/temp; absent (we only expose load percent).
- **[Skip]** **Per-axis acceleration / jerk / feed-per-rev** — MTConnect (`AccelerationSpec`, `Jerk`, `Feedrate`). Why: Beyond actual feed; absent.
- **[Build]** **Cycle time per part / last cycle time / cycle start timestamp** — MTConnect (`ProcessTimer`), Memex. Why: We expose accumulating timers but not "last completed cycle" deltas.
- **[Skip]** **`cnc_rdrelpos` reset / preset, `cnc_setpath`, `cnc_wrabsmac`** — operator-style write commands. Why: Read-only-by-design covers it, but commercial parity assumes selective writes.
- **[Skip]** **CNC time/date sync (`cnc_rdtimer` clock variant / `cnc_rtime`)** — Kepware, Memex. Why: Setting CNC system clock from a master time source is common in audited environments; absent.
- **[Build]** **Connection-level statistics + retry counters surfaced as variables** — Kepware exposes per-channel stats; we publish health but not as variables.
### Recommendations
| # | Gap | Build? | Rationale |
|---|-----|:------:|-----------|
| 1 | Writes (parameters / PMC / macro) | Yes | Key MES feedback path; current read-only is too narrow |
| 2 | HSSB transport | No | PCI hardware; declining; reopens fwlib distribution problem |
| 3 | FOCAS password / unlock | Yes | Cheap once writes ship; some controllers gate reads too |
| 4 | Multi-path / multi-channel CNC | Yes | 30i/31i/32i routinely have multiple paths |
| 5 | Series 15 / Power Mate D-H / Series 35i | No | Very legacy; small install base |
| 6 | `cnc_getfigure` decimal scaling | Yes | Already TODO; clients shouldn't compute scaling |
| 7 | Modal G-code / M-code state | Yes | One of the most-asked CNC tag groups |
| 8 | Tool number / tool life management | Yes | Core MES integration data |
| 9 | Tool offset table read / write | No | Write-heavy; defer with general write decision |
| 10 | Work coordinate offsets (G54..) | Yes | Setup automation needs read / poke |
| 11 | Override values (Feed / Rapid / Spindle / Jog) | Yes | OEE / MES bread-and-butter |
| 12 | ODBST status flags as nodes | Yes | Cheap; project the 9 fields we already read |
| 13 | Parts count in fixed tree | Yes | MES table-stakes; simple `cnc_rdparam` projection |
| 14 | Diagnostic numbers (`cnc_rddiag`) | Yes | Predictive maintenance |
| 15 | PMC F / G letters for 16i | Yes | Correctness; real ladders use F/G handshakes |
| 16 | Bulk PMC range read | Yes | Big perf gain at scale |
| 17 | Alarm history (`cnc_rdalmhistry`) | Yes | Auditing; small extension to alarm projection |
| 18 | Operator messages (`cnc_rdopmsg*`) | Yes | Cheap; common macro feedback |
| 19 | Program list / upload / download / delete | No | DNC product territory; significant scope |
| 20 | Currently-executing program text | Yes | HMI displays expect block view |
| 21 | DPRNT TCP listener | No | Significant scope; modern paths supersede it |
| 22 | Servo / spindle deep info | No | Specialty; load% covers most needs |
| 23 | Per-axis acceleration / jerk / feed-per-rev | No | Niche advanced telemetry |
| 24 | Cycle time per part / last cycle delta | Yes | OEE-essential |
| 25 | Operator write commands (preset etc.) | No | Read-only design choice; revisit only with general writes |
| 26 | CNC time / date sync | No | Rare ask; commonly handled by CNC NTP |
| 27 | Connection statistics as variables | Yes | Cheap given existing health |
### Notable parity (keep)
- Pure-managed wire client (no Fwlib distribution problem) — significant operational win vs Kepware's HSSB driver DLL stack.
- Per-series capability matrix at `InitializeAsync` time prevents silent runtime `BadOutOfRange` on misconfigured macro/parameter/PMC numbers.
- Fixed-tree per-API capability probes auto-suppress nodes the CNC doesn't support — operators don't see nodes that perpetually return `BadDeviceFailure`.
- `IPerCallHostResolver` integrates each device into the shared resilience bulkhead (Phase 6.1) — comparable to Kepware's per-device "channel" isolation.
- Three-tier poll cadence (axis fast / program medium / timer slow) is closer to MTConnect adapter behaviour than Kepware's single-rate channel scan.
- Handle-recycle loop is a thoughtful defence against documented Fanuc handle-leak firmware bugs — not present in many commercial drivers.
- Alarm projection differentiates raise vs clear and maps `ALM_TYPE_*` to OPC UA severity buckets — closer to A&E semantics than the simple "alarm bit" Kepware exposes.
### Sources
- https://www.kepware.com/en-us/products/kepserverex/drivers/fanuc-focas-hssb-ethernet/ — Kepware Fanuc Focas HSSB and Ethernet Driver
- https://github.com/mtconnect/cppagent_dev/tree/main/agent/adapter/fanuc — MTConnect Fanuc adapter reference
- https://github.com/Ladder99/focas-mock — managed Focas wire client (the OSS basis we consume)
- https://www.inductiveautomation.com/exchange/2218 — Ignition Fanuc FOCAS driver module
- https://memex.ca/merlin-tempus-mes-suite/ — Memex Merlin OEE / Fanuc connectivity
- https://www.predator-software.com/cnc-data-collection.htm — Predator MDC / DNC capabilities
- https://www.forcam.com/en/products/factory-data-collection/ — Forcam Force MES Fanuc driver
- Fanuc FOCAS Developer Kit `fwlib32.h` (mirrored at `strangesast/fwlib`) — authoritative API surface
- https://www.mtconnect.org/standard-2 — MTConnect Standard Part 2 Devices Information Model
---
## OpcUaClient (OPC UA Aggregation Client)
### What we ship today
- **Endpoint config**: single `EndpointUrl` plus ordered `EndpointUrls` failover list with `PerEndpointConnectTimeout` per-attempt budget (`OpcUaClientDriverOptions.cs:22-40`); failover sweep tries each in order on init and on session drop (`OpcUaClientDriver.cs:95-118`).
- **Security policies**: `None`, `Basic128Rsa15`, `Basic256`, `Basic256Sha256`, `Aes128_Sha256_RsaOaep`, `Aes256_Sha256_RsaPss` plus `Sign` / `SignAndEncrypt` modes; explicit policy+mode matching against the server's `GetEndpoints` response, no silent fallback to a weaker cipher (`OpcUaClientDriver.cs:299-336`).
- **Identity tokens**: Anonymous, Username/Password, and X509 user-certificate (PFX with private key) — built once and reused across every failover attempt (`OpcUaClientDriver.cs:244-369`).
- **Certificate management**: per-process PKI store rooted at `%LocalAppData%\OtOpcUa\pki` with own/trusted/issuers/rejected directories; SDK auto-creates the application instance certificate at startup; `AutoAcceptCertificates` dev knob hooks the validator's `BadCertificateUntrusted` path (`OpcUaClientDriver.cs:163-217`).
- **Session lifecycle**: configurable `SessionTimeout`, `KeepAliveInterval`, `ReconnectPeriod`, `ApplicationUri`, `SessionName`, operation `Timeout` (`OpcUaClientDriverOptions.cs:82-112`).
- **Reconnect**: native `Session.KeepAlive` event drives a `SessionReconnectHandler` with a 2-minute max retry period; SDK's automatic `TransferSubscriptions` migrates monitored items onto the rebuilt channel; keep-alive is rewired onto the new session post-recovery (`OpcUaClientDriver.cs:1297-1359`).
- **Discovery**: two-pass recursive browse from `BrowseRoot` (default `ObjectsFolder`) with `MaxBrowseDepth=10` and `MaxDiscoveredNodes=10_000` caps; pass 2 batch-reads `DataType` + `ValueRank` + `UserAccessLevel` + `Historizing` per variable in one Session.ReadAsync (`OpcUaClientDriver.cs:596-810`).
- **Type mapping**: built-in OPC UA scalar types → `DriverDataType`; structs/enums/extension objects fall through to String passthrough; `ValueRank>=0` flags arrays (`OpcUaClientDriver.cs:820-836`).
- **ACL bridge**: `UserAccessLevel.CurrentWrite``SecurityClassification.Operate`, otherwise `ViewOnly`; gating happens server-side in DriverNodeManager (`OpcUaClientDriver.cs:844-850`).
- **Read/Write**: batched ReadAsync/WriteAsync with NodeId pre-parse + per-tag `BadNodeIdInvalid` short-circuit; cascading-quality preserves upstream `StatusCode` and `SourceTimestamp` verbatim; transport faults fan out as `BadCommunicationError` (`OpcUaClientDriver.cs:441-568`).
- **Subscriptions**: native MonitoredItem forwarding with publishing-interval floor of 50 ms, `KeepAliveCount=10`, `LifetimeCount=1000`, `QueueSize=1`, `DiscardOldest=true`, `Reporting` mode, `TimestampsToReturn.Both` (`OpcUaClientDriver.cs:854-914`).
- **Alarms (A&C)**: EventFilter SelectClauses on `BaseEventType` + `ConditionType` (EventId/EventType/SourceNode/Message/Severity/Time/ConditionId), source-node filter set, `QueueSize=1000` for burst tolerance, `Acknowledge` method invocation forwarded as `CallAsync`; severity bucketed Low/Medium/High/Critical per OPC UA Part 9 (`OpcUaClientDriver.cs:967-1143`).
- **HistoryRead pass-through**: `ReadRawAsync`, `ReadProcessedAsync` (Average/Min/Max/Total/Count standard aggregates), `ReadAtTimeAsync` with continuation point support (`OpcUaClientDriver.cs:1154-1264`).
- **Diagnostics**: per-driver `HostName` reflects the URL actually connected (not the first candidate); `HostState` transitions Running/Stopped/Unknown driven by keep-alive; `DriverHealth` carries `LastSuccessfulRead` + last error (`OpcUaClientDriver.cs:1281-1372`).
- **Capability surface**: 8/8 — `IDriver`, `ITagDiscovery`, `IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider`.
### Gaps vs commercial UA aggregators
- **[Build]** **Reverse Connect (server-initiated client connect)** — present in: UaGateway, Prosys Forge, Kepware (1.5+), Matrikon. Why: lets the upstream server traverse outbound-only firewalls (typical OT-DMZ direction); a hard requirement for many regulated plant networks.
- **[Build]** **Discovery URL with `FindServers` / `FindServersOnNetwork`** — present in: Kepware, UaGateway, Matrikon. Why: we accept only an explicit endpoint URL; commercial gateways resolve a discovery URL and let the operator pick from advertised endpoints in a UI without copying the policy/mode tuple by hand.
- **[Skip]** **Multicast / LDS-ME registration** — present in: UaGateway, Prosys. Why: lets clients discover this gateway via the Local Discovery Server without static config.
- **[Skip]** **GDS push management (Part 12)** — present in: UaGateway, Prosys. Why: certificate provisioning, renewal, trust-list updates pushed from a central GDS — required for fleets >10 endpoints; we have no `ServerConfigurationType` method support and no automatic renewal hook.
- **[Build]** **Per-tag advanced subscription tuning** — present in: Kepware, UaGateway, Cogent. Why: `SamplingInterval`, `QueueSize`, `DiscardOldest`, `MonitoringMode`, `DataChangeFilter` (DeadbandType=Absolute/Percent, Trigger=Status/StatusValue/StatusValueTimestamp) are hard-coded (50 ms / 1 / true / Reporting / no deadband). No way to set deadbands per tag — a baseline aggregator feature for analog noise filtering.
- **[Build]** **Per-subscription tuning (`PublishingInterval` / `KeepAliveCount` / `LifetimeCount` / `MaxNotificationsPerPublish` / `Priority`)** — present in: all listed gateways. Why: we hard-code 10/1000/0/0 in `Subscription` and `MaxNotificationsPerPublish=0` (unlimited) is a denial-of-service surface against high-event-rate servers; high-tag-count deployments need to split subscriptions across priorities.
- **[Build]** **Selective import / namespace remap** — present in: Kepware, Matrikon, UaGateway, Cogent. Why: we mirror everything under `BrowseRoot` and re-prefix with a single "Remote" folder; commercial aggregators support per-branch include/exclude rules, namespace-URI remapping, alias paths, and re-keyed BrowseNames.
- **[Build]** **Type definition mirroring (ObjectTypes / VariableTypes / DataTypes / ReferenceTypes)** — present in: UaGateway, Prosys, Kepware. Why: we walk Object + Variable nodes only; HasTypeDefinition references and custom type nodes are dropped, so downstream UI clients lose type-aware rendering and structured DataTypes decode as String passthrough.
- **[Build]** **Method node mirroring + pass-through `Call`** — present in: UaGateway, Matrikon, Kepware. Why: `NodeClass.Method` is filtered out of the browse and `IDriver` has no `CallMethodAsync` capability; clients cannot invoke remote methods through the gateway. (`Acknowledge` is the only call we forward, hard-coded for A&C.)
- **[Build]** **Automatic re-import on remote `ServerStatus.NodeVersion` / `ModelChangeEvent`** — present in: UaGateway, Kepware, Prosys. Why: we don't subscribe to `ServerStatus.State` or `BaseModelChangeEventType`; if the upstream server adds nodes mid-flight the new tags don't appear until the driver is reinitialized.
- **[Skip]** **HistoryUpdate / HistoryRead-Modified / Annotation pass-through** — present in: UaGateway, Prosys Historian, Kepware (LocalHistorian). Why: we ship Raw/Processed/AtTime only; `IsReadModified=false` is hard-coded; no `HistoryUpdate`, no `DeleteRawModified`, no annotation forwarding. Many MES integrations need backfill writes.
- **[Build]** **`ReadEventsAsync` (HistoryRead Events)** — explicitly deferred per memory entry. Why: `IHistoryProvider.ReadEventsAsync` interface lacks an `EventFilter SelectClauses` parameter to carry the field projection.
- **[Build]** **Aggregate function set** — present in: UaGateway, Prosys, Kepware. Why: we map only Average/Minimum/Maximum/Total/Count; OPC UA Part 13 standard catalog has 30+ (TimeAverage, Interpolative, StdDev, DurationGood, NumberOfTransitions, etc.) that historian-class clients expect.
- **[Build]** **Redundant-server URI list (`ServerUriArray`) and transparent failover** — present in: Kepware, UaGateway, Matrikon. Why: our `EndpointUrls` is a one-shot connect-attempt list, not a live redundancy group; we don't read the upstream `ServerRedundancyType` or fail over mid-session on `ServiceLevel` drop.
- **[Build]** **Maximum nodes per Read/Write/Browse honored from server capabilities** — present in: all listed gateways. Why: we delegate chunking to the SDK but never query `Server.ServerCapabilities.OperationLimits.MaxNodesPerRead/Write/Browse`; on undersized servers this can produce `BadTooManyOperations` instead of automatic fragmentation.
- **[Skip]** **Connection / session pooling for multi-instance scale-out** — present in: UaGateway, Cogent. Why: each driver instance opens its own session even when N drivers point at the same upstream; commercial gateways multiplex one session per remote across multiple downstream contexts to cut session count and cert-handshake load.
- **[Build]** **Diagnostics counters (PublishRequest count, NotificationsPerSecond, MissingPublishRequests, dropped-notification rate)** — present in: UaGateway, Prosys. Why: `DriverHealth` carries `LastSuccessfulRead` + last error string only; no per-server message-rate counters or publish-queue health metrics for the Admin dashboard.
- **[Skip]** **Kerberos / OAuth2 / IssuedToken (JWT) user identity** — present in: Kepware (Kerberos), UaGateway, Prosys. Why: we support Anonymous/Username/Certificate only; no `IssuedIdentityToken` token type, no Kerberos SPNEGO, no JWT bearer flow that newer security stacks (Azure AD) expect.
- **[Skip]** **WriteAsync attribute scope beyond Value** — present in: UaGateway, Matrikon. Why: `WriteAsync` hard-codes `AttributeId = Attributes.Value`; no way to write `StatusCode`, `SourceTimestamp`, or non-Value attributes (rare but a documented OPC UA capability).
- **[Build]** **CRL / revocation list configuration** — present in: Kepware, UaGateway. Why: the cert-validator hooks `BadCertificateUntrusted` only; revoked-cert chains aren't explicitly checked or surfaced as a distinct fault, and there's no `RejectSHA1SignedCertificates` knob.
### Recommendations
| # | Gap | Build? | Rationale |
|---|-----|:------:|-----------|
| 1 | Reverse Connect | Yes | OT-DMZ outbound-only is the standard plant-network direction |
| 2 | Discovery URL `FindServers` | Yes | Standard UX; saves manual policy / mode tuple copy |
| 3 | Multicast / LDS-ME registration | No | Server-side responsibility, not aggregator's |
| 4 | GDS push management (Part 12) | No | Significant infra; rare for our deployment scale |
| 5 | Per-tag advanced subscription tuning (deadband, queue, mode) | Yes | Deadbands are baseline analog filtering |
| 6 | Per-subscription tuning (publishing / keep-alive / lifetime) | Yes | Avoid DoS on bursty servers; operability |
| 7 | Selective import / namespace remap | Yes | Curation is a baseline aggregator feature |
| 8 | Type definition mirroring | Yes | UI clients lose structure decoding without it |
| 9 | Method node mirroring + `Call` passthrough | Yes | Clear functional gap; `IDriver` capability missing |
| 10 | Auto re-import on `ModelChangeEvent` | Yes | Correctness when remote topology changes |
| 11 | HistoryUpdate / Modified / Annotation passthrough | No | MES backfill scope; defer |
| 12 | `ReadEventsAsync` (HistoryRead Events) | Yes | Fix the `IHistoryProvider` abstraction gap |
| 13 | Full Aggregate function set (Part 13) | Yes | Cheap to forward; historian clients expect it |
| 14 | `ServerUriArray` redundant failover | Yes | HA expectation when upstream is redundant |
| 15 | Honor server `OperationLimits` | Yes | Correctness; avoids `BadTooManyOperations` |
| 16 | Connection / session pooling | No | Premature; current per-instance model is simple and adequate |
| 17 | Diagnostics counters | Yes | Operability; admin dashboard needs publish-rate visibility |
| 18 | Kerberos / OAuth2 / JWT identity | No | Significant security work; defer until AD integration drives it |
| 19 | Write attribute scope beyond Value | No | Niche; rarely used in OPC UA practice |
| 20 | CRL / revocation handling | Yes | Security baseline expectation |
### Notable parity (keep)
- Cascading-quality contract: upstream `StatusCode` and `SourceTimestamp` preserved verbatim across Read, Subscribe, History — a baseline OPC-to-OPC bridging requirement.
- Native subscription forwarding (no polling translation layer) — matches Kepware/UaGateway architecture, not Matrikon Tunneller's COM-bridge approach.
- Two-pass discovery batching attribute reads — many naive aggregators issue per-node Reads which makes 10k-node servers take minutes.
- Explicit policy+mode endpoint matching (no silent downgrade) — matches UaGateway's behavior; Kepware historically defaulted to "best available" which has been a CVE source.
- Per-endpoint connect-timeout in failover sweep — bounded init budget is a property most of the listed gateways added late.
- SDK-managed `TransferSubscriptions` on reconnect — matches the OPC Foundation reference behavior; no hand-rolled migration code.
### Sources
- OPC Foundation UA-.NETStandard SDK docs — https://github.com/OPCFoundation/UA-.NETStandard
- Kepware KEPServerEX OPC UA Client — https://www.ptc.com/en/products/kepware/kepserverex/clients/opc-ua-client
- Matrikon OPC UA Tunneller — https://www.matrikonopc.com/products/opc-tunneller/
- Unified Automation UaGateway — https://www.unified-automation.com/products/wrapper-and-gateway/ua-gateway.html
- Prosys OPC UA Forge / Historian — https://www.prosysopc.com/products/opc-ua-forge/
- Cogent DataHub OPC UA — https://www.cogentdatahub.com/products/opc-ua/
- AVEVA System Platform OI.UACLIENT — https://docs.aveva.com (Operations Integration UACLIENT)
- OPC UA Part 4 (Services), Part 5 (Information Model), Part 9 (A&C), Part 11 (HistoricalAccess), Part 12 (Discovery & GDS), Part 13 (Aggregates), Part 14 (PubSub) — https://reference.opcfoundation.org/
---
## S7 (Siemens S7-300/400/1200/1500)
### What we ship today
- Native S7comm over ISO-on-TCP via S7netplus; default port 102, configurable so an in-CI Snap7 server can bind 1102 (`S7DriverOptions.cs:32`, `S7Driver.cs:87`).
- CPU family selector — `S71200`, `S71500`, `S71200Smart`, `S7200`, `S7300`, `S7400` — enum forwarded straight to S7netplus to pick the remote TSAP slot byte (`S7DriverOptions.cs:34-38`).
- Rack/slot configuration with documented conventions (S7-300 slot 2, S7-400 slot 2/3, S7-1200/1500 slot 0) (`S7DriverOptions.cs:42-51`).
- Single-connection-per-PLC policy enforced by a `SemaphoreSlim` because the CPU's comms mailbox is scanned at most once per cycle (`S7Driver.cs:23-27,60-67`).
- Static tag table parsed at `InitializeAsync` so syntactic typos fail fast instead of bleeding through as `BadInternalError` per read (`S7Driver.cs:103-110`).
- Address parser accepts DB / M / I / Q / T / C with X/B/W/D widths and 0-7 bit offsets, case-insensitive, with structured `FormatException` messages (`S7AddressParser.cs:65-216`).
- Scalar reads/writes for Bool, Byte, Int16/UInt16, Int32/UInt32, Float32 with explicit signed/unsigned reinterpret of S7netplus' boxed unsigned return values (`S7Driver.cs:231-251,306-322`).
- PUT/GET-disabled detection — `S7.Net.PlcException` mapped to `BadDeviceFailure` and surfaced as a configuration alert rather than retried via Polly (`S7Driver.cs:200-208`, `S7DriverOptions.cs:14-25`).
- Polled `ISubscribable` overlay floored at 100 ms to avoid wire-side queueing past CPU scan; per-tag last-value diffing for change-of-value publishing (`S7Driver.cs:365-425`).
- `IHostConnectivityProbe` using `ReadStatusAsync` (CPU Run/Stop) every probe interval, gated on the same semaphore so it doesn't race a live read (`S7Driver.cs:457-489`).
- Per-tag `WriteIdempotent` flag for replay-safe write retry policy (`S7DriverOptions.cs:91-104`).
- Snap7-server-backed integration fixture covers atomic typed reads + DB write-then-read round-trip on `localhost:1102` (`docs/drivers/S7-Test-Fixture.md:1-60`).
- Test CLI — probe / read / write / subscribe — with the same address grammar and CPU/slot flags (`docs/Driver.S7.Cli.md`).
### Gaps vs commercial gateways
- **[Build]** **S7-1500 Optimized DB / Symbolic addressing (S7Plus)** — present in: Kepware "Siemens S7 Plus", Ignition, AVEVA OI.SIDIRECT (limited). Why: S7netplus speaks classic S7comm only; optimized DBs reorder fields and have no fixed byte offsets, so absolute `DB1.DBW0` reads return `BadDeviceFailure` until "Optimized block access" is unchecked in TIA Portal.
- **[Build]** **PDU size negotiation surfaced to operators** — present in: Kepware, TOP Server, AVEVA OI.SIDIRECT. Why: Modern S7 CPUs negotiate PDU sizes from 240 up to 960 bytes; we accept whatever S7netplus negotiates with no operator visibility into the cap and no per-request packing strategy that uses the negotiated size.
- **[Build]** **Multi-variable PDU packing / read coalescing** — present in: every commercial gateway. Why: `ReadAsync(IReadOnlyList<string>)` issues one S7netplus call per tag inside the semaphore (`S7Driver.cs:182-214`); commercial gateways bin-pack contiguous DB ranges into a single multi-item PDU which is 5-50× faster on dense tag groups.
- **[Build]** **TSAP / Connection Type selector (PG / OP / S7-Basic / Other)** — present in: Kepware, TOP Server, AVEVA. Why: S7netplus picks PG-style TSAPs; sites that need OP-class slots (e.g. fenced HMI connections, license-counted PG slots) cannot pick. Some S7-1500 hardening modes refuse PG access from non-allowlisted clients.
- **[Build]** **Symbol-table / TIA Portal export browse** — present in: Kepware (online symbol upload on S7-1500), Ignition (TIA tag CSV import), TOP Server (tag-import wizard from `.AWL`/`.udt`/`.xml`). Why: We ship a static tag table only (`S7DriverOptions.cs:55-57`); operators must hand-edit the JSON. No `.tia`/`.s7p` import, no online symbol read of the S7-1500 PG symbol table.
- **[Build]** **UDT / STRUCT / nested-DB handling** — present in: Kepware, Ignition, TOP Server. Why: Tag map is flat scalar-only — no UDT fan-out into member variables, no `Array of <UDT>` indexing. Real S7-1500 projects expose hundreds of UDT-typed DBs.
- **[Build]** **Array tags (ValueRank=1)** — present in: every commercial gateway. Why: `S7TagDefinition` has no array dimension; `MapDataType` always returns `IsArray: false` (`S7Driver.cs:337-345`). OPC UA arrays of S7 `Array[0..n]` are unaddressable.
- **[Build]** **STRING / WSTRING / DTL / S5TIME / TIME / DATE_AND_TIME read+write** — present in: every commercial gateway. Why: Enum entries exist but every code path throws `NotSupportedException` (`S7Driver.cs:241-245,316-320`); S7 `STRING` has a 2-byte header, `WString` is UTF-16 with a 4-byte header, `DTL` is 12 bytes, `S5TIME` is BCD-encoded — none are wired up.
- **[Build]** **64-bit types (LInt / ULInt / LReal / LWord)** — present in: Kepware S7 Plus, Ignition, TOP Server S7-1500 driver. Why: `Int64`/`UInt64`/`Float64` cases throw `NotSupportedException` (`S7Driver.cs:241-243`); S7-1500 `LReal` (8-byte double) is the standard analog representation in modern projects.
- **[Build]** **Instance-DB / FB-block parameter access** — present in: Kepware, Ignition (with TIA import). Why: We address by absolute DB number; instance DBs of multi-instance FBs need symbolic resolution (`MyFB_Instance.MyParam`) which our parser doesn't accept.
- **[Build]** **CPU diagnostic buffer / SZL reads** — present in: Kepware (CPU diagnostic tags), TOP Server (`@Diagnostic` tags), AVEVA OI.SIDIRECT. Why: We probe `ReadStatusAsync` only (`S7Driver.cs:476`); SZL IDs 0x0000-0xFFFF (CPU type, firmware version, cycle time min/max/avg, diagnostic-buffer entries, hardware module status) are not exposed as system tags.
- **[Skip]** **AS-Alarms / Alarm_S/SQ/D/DQ / S7 ProDiag** — present in: Kepware (Alarms suite), Ignition. Why: No `IAlarmSource` implementation; CPU-resident alarms (Alarm_S blocks, ProDiag supervision messages, system diagnostic messages) are invisible to OPC UA A&E clients. CPU diagnostic-buffer entries similarly not surfaced.
- **[Skip]** **CPU Run/Stop control / block download / PG functions** — present in: Kepware (limited), AVEVA OI.SIDIRECT. Why: `ReadStatusAsync` is the only PG-class call we make; remote `WriteCpuStop` / `WriteCpuStart`, block download, password authentication for PG functions are absent.
- **[Build]** **PLC password / protection-level handling** — present in: Kepware, TOP Server, AVEVA. Why: S7-300/400 protection levels 1-3 and S7-1200/1500's "Connection mechanisms" / "Full access incl. fail-safe" tiers can require a password on connect; S7netplus's `Plc` ctor takes no password and we have no place to plumb one through.
- **[Skip]** **S7-1500 "Secure Communication" (TLS / certificate-based)** — present in: Siemens-direct (OPC UA on S7-1500), Kepware S7 Plus partial. Why: S7-1500 firmware V3.0+ supports authenticated PG connections with certificates; we connect plaintext over TCP only. Sites with hardened CPUs (`Access protection = high` + cert required) won't accept the driver.
- **[Skip]** **S7-400H / redundant H-system support** — present in: Kepware (paired-IP with sticky-master), AVEVA OI.SIDIRECT. Why: We have one host/port; H-systems present two sync'd CPUs on two IPs and the driver should fail over without losing subscriptions. Driver-level redundancy is unimplemented (server-level redundancy in `docs/Redundancy.md` is a separate axis).
- **[Skip]** **Multi-CPU rack / multiple TSAPs per rack** — present in: Kepware, TOP Server. Why: One Plc instance binds one (rack, slot); S7-400 multi-CPU racks expose 2-4 CPUs that need parallel sessions to drive in parallel.
- **[Skip]** **MPI / Profibus / RFC1006-routed transports** — present in: Kepware, AVEVA OI.SIDIRECT (DASSIDirect legacy paths), TOP Server. Why: S7netplus is Ethernet-only. Brownfield S7-300 sites still routed via CP 5611/5613 MPI cards or via S7-1500-as-router for fenced subnets are out of reach.
- **[Build]** **LOGO! 8 / S7-200 / S7-200 Smart variant tuning** — present in: Kepware "Siemens TCP/IP Ethernet" (LOGO!), Sharp7 (S7-200 Smart), Ignition. Why: `CpuType.S7200`/`S7200Smart` exists in S7netplus but the V-memory area (`V` letter) is not in our parser's switch (`S7AddressParser.cs:88-97`). LOGO!'s VM range and S7-200's V/SM areas are unaddressable.
- **[Build]** **Per-tag scan group / publish rate** — present in: Kepware (scan classes), Ignition (tag groups), TOP Server (scan rate per tag). Why: Subscriptions take one publishingInterval for the whole tag list (`S7Driver.cs:365-380`); a CPU with mixed 100 ms / 1 s / 10 s tags needs three subscribe calls and three semaphore-serialized poll loops.
- **[Build]** **Deadband / on-change suppression with absolute or percent thresholds** — present in: every commercial gateway. Why: We diff exact-equal only (`S7Driver.cs:419`); no analog deadband — a noisy float tag floods the bus.
- **[Build]** **Block-read coalescing for contiguous DB regions** — present in: every commercial gateway. Why: Reading `DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` issues 3 calls; commercial drivers issue a single FC=04 ReadVarRequest covering bytes 0-5 and slice client-side.
- **[Skip]** **Connection-resource budget management / max-parallel-jobs (AmqLen)** — present in: Kepware, TOP Server. Why: S7-1200/1500 expose 8-64 connection-resources and a per-connection parallel-jobs cap (Amq); we hold one connection and serialize, but commercial drivers open 2-4 connections per CPU to multiplex. We have no operator knob.
- **[Build]** **Pre-flight / online-test of PUT/GET enablement** — present in: Kepware (config validation step), AVEVA. Why: We surface `BadDeviceFailure` only at first read (`S7Driver.cs:200-208`); commercial drivers warn during connection wizard via SZL probe before the operator commits config.
### Recommendations
| # | Gap | Build? | Rationale |
|---|-----|:------:|-----------|
| 1 | S7-1500 Optimized DB / Symbolic addressing (S7Plus) | Yes | Hard blocker on modern S7-1500 sites |
| 2 | PDU size negotiation surfaced | Yes | Cheap operability; no behavior change |
| 3 | Multi-variable PDU packing | Yes | 5-50x perf; current per-tag-per-call is the baseline gap |
| 4 | TSAP / Connection Type selector | Yes | Hardened CPUs reject PG-class slots |
| 5 | Symbol-table / TIA Portal export browse | Yes | Workflow parity; static JSON doesn't scale |
| 6 | UDT / STRUCT / nested-DB handling | Yes | Real S7-1500 projects expose hundreds of UDTs |
| 7 | Array tags (ValueRank=1) | Yes | Table-stakes; currently unaddressable |
| 8 | STRING / WSTRING / DTL / S5TIME / TIME / DT | Yes | Standard datatypes; currently throw `NotSupported` |
| 9 | 64-bit types (LInt / ULInt / LReal / LWord) | Yes | LReal is the standard analog representation on S7-1500 |
| 10 | Instance-DB / FB parameter access | Yes | Modern symbolic structure; absolute DBs alone are limiting |
| 11 | CPU diagnostic buffer / SZL reads | Yes | Operability; firmware / cycle-time visibility |
| 12 | AS-Alarms / Alarm_S / ProDiag | No | Significant scope; alarms are a separate workstream |
| 13 | CPU Run / Stop control / block download | No | Security / safety risk; out of scope |
| 14 | PLC password / protection-level handling | Yes | Hardened CPUs require it (S7netplus support permitting) |
| 15 | S7-1500 Secure Communication / TLS | No | Significant work; defer |
| 16 | S7-400H redundant H-system support | No | Rare in our deployment scope |
| 17 | Multi-CPU rack parallel sessions | No | Rare; one session per CPU works |
| 18 | MPI / Profibus / RFC1006-routed transports | No | Declining; brownfield only |
| 19 | LOGO! 8 / S7-200 V-memory area | Yes | Small parser fix broadens coverage materially |
| 20 | Per-tag scan group / publish rate | Yes | Operability; mixed-rate is normal |
| 21 | Deadband / on-change with thresholds | Yes | Analog noise mitigation |
| 22 | Block-read coalescing for contiguous DBs | Yes | Big perf win; complements multi-variable PDU packing |
| 23 | Connection-resource budget / parallel jobs | No | Premature; one connection works for most rigs |
| 24 | Pre-flight PUT/GET enablement test | Yes | UX improvement; cheap |
### Notable parity (keep)
- Single-connection-per-PLC + semaphore serialization is the documented S7netplus / Snap7 best practice and matches what TOP Server / AVEVA do in their default profile.
- 100 ms minimum publishing interval correctly reflects CPU mailbox scan reality — commercial gateways advertise "1 ms scan" in marketing then quietly floor to ~100 ms in practice.
- Strict address-parse-at-init with structured exceptions (rather than per-read `BadInternalError`) is better operator UX than Kepware's "you'll find out at runtime" default.
- PUT/GET-disabled mapped to a sticky `BadDeviceFailure` instead of being retried by Polly — Polly retry against a CPU that will keep refusing is exactly the failure mode that floods commercial deployments.
- `WriteIdempotent` per-tag flag is finer-grained than Kepware's connection-level `Auto Demote` and matches the safe-replay reality: DB set-points are replayable, M/Q edge-triggered bits are not.
- Probe path uses `ReadStatusAsync` (single CPU-state PDU) rather than a tag read — doubles as "PLC actually up" without polluting the comms mailbox.
- Driver-instance host/port format (`host:port`) matches the Modbus driver so Admin UI can render both families uniformly.
- Snap7-server CI fixture closes the "no commercial vendor offers a meaningful S7 simulator" gap that Kepware/TOP Server users hit on day one.
### Sources
- https://www.kepserverexopc.com/products/siemens-tcpip-ethernet/ (Kepware Siemens TCP/IP Ethernet)
- https://www.kepware.com/en-us/products/kepserverex/drivers/siemens-s7-plus/ (Kepware S7 Plus — Optimized DB / Symbolic addressing)
- https://www.aveva.com/en/products/communication-drivers/ (AVEVA OI Server / DASSIDirect)
- https://www.softwaretoolbox.com/topserver-siemens-suite (TOP Server Siemens Suite)
- https://docs.inductiveautomation.com/docs/8.1/platform/connecting-to-devices/siemens (Ignition Siemens driver guide)
- https://github.com/S7NetPlus/s7netplus (S7netplus library)
- https://snap7.sourceforge.net/ (Snap7)
- https://github.com/evcc-io/sharp7 (Sharp7 fork — S7-1200/1500 PUT/GET semantics)
- https://cache.industry.siemens.com/dl/files/591/68018591/att_956083/v1/s71500_communication_function_manual_en-US_en-US.pdf (Siemens S7-1500 Communication Function Manual)
- https://support.industry.siemens.com/cs/document/26224811 (Siemens — TSAPs and connection resources)
- https://support.industry.siemens.com/cs/document/89260861 (Siemens — SZL list IDs / system status lists)
- https://docs.tia.siemens.cloud/r/en-us/v20/safety-and-security/secure-communication (S7-1500 secure communication)
---
## TwinCAT (Beckhoff ADS)
### What we ship today
- `TwinCATDriver` implements `IReadable`, `IWritable`, `ISubscribable`, `ITagDiscovery`, `IHostConnectivityProbe`, `IPerCallHostResolver` over Beckhoff's `Beckhoff.TwinCAT.Ads` v6 `AdsClient` (`src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs:11-12`, `AdsTwinCATClient.cs:22-24`).
- AMS addressing parses `ads://{netId}:{port}` with the six-octet AmsNetId and TC3 default port 851 (also documents 801/811/821 for TC2 and 10000 for system service) (`TwinCATAmsAddress.cs:20-64`).
- Native ADS notifications via `AddDeviceNotificationExAsync` with `AdsTransMode.OnChange` and per-tag cycle time; falls back to shared `PollGroupEngine` when `UseNativeNotifications=false` (`TwinCATDriver.cs:296-339`, `AdsTwinCATClient.cs:130-160`).
- IEC 61131-3 atomic data type surface — Bool, S/U Int 8/16/32/64, Real, LReal, String, WString, Time, Date, DT, TOD (`TwinCATDataType.cs:9-30`).
- Symbol path parser supports POU/GVL prefix, struct member walks, array subscripts incl. multi-dim `Matrix[1,2]`, and bit-access `.0..31` (`TwinCATSymbolPath.cs:1-104`).
- Bit-indexed BOOL **read** path: read parent word as `uint`, mask locally (`AdsTwinCATClient.cs:57-64`, `ExtractBit`).
- Optional controller-side symbol browse via `SymbolLoaderFactory` (flat mode), with system-symbol filter for `TwinCAT_*`, `Constants.*`, `Mc_*`, `__*` (`AdsTwinCATClient.cs:178-195`, `TwinCATSystemSymbolFilter.cs`).
- Per-device probe loop calls `ReadStateAsync` and emits `OnHostStatusChanged` Running/Stopped transitions (`TwinCATDriver.cs:366-402`).
- Status-code mapping `AdsErrorCode` → OPC UA via `TwinCATStatusMapper`; auto-reconnect on dropped client (`TwinCATDriver.cs:413-429`).
- Sized strings `STRING(80)` / `WSTRING(80)` are tolerated in browse — type name parens stripped to bare atom (`AdsTwinCATClient.cs:200-206`).
- Live-tested against TCBSD VM and Hyper-V XAR — 30 integration test cases (read/write/array/subscribe/browse/reconnect/probe), 110 unit tests (`docs/drivers/TwinCAT-Test-Fixture.md`).
### Gaps vs commercial gateways
- **[Build]** **ADS Sum commands (sum-read / sum-write / sum-add-notification)** — present in: Kepware, TF6100, Ignition, TwinCAT.Ads itself. Why: we issue one `ReadValueAsync` per tag in a loop (`TwinCATDriver.cs:118-156`); commercial drivers batch into `IndexGroup=0xF080..0xF084` sum requests for ~10x throughput on multi-thousand tag scans.
- **[Build]** **Handle-based access (CreateVariableHandle / ReadByHandle)** — present in: Kepware, TF6100, AdsClient itself. Why: we resolve the symbolic name on every read; cached handles cut per-request bytes and AMS overhead, especially over WAN/multi-hop.
- **[Build]** **STRUCT / UDT decomposition with offline TMC parsing** — present in: Kepware (TwinCAT TMC import), TF6100 (native), Ignition. Why: `TwinCATDataType.Structure` is declared but discovery skips non-atomic symbols (`AdsTwinCATClient.cs:224`); we can't expose nested UDT trees without hand-declaring every leaf.
- **[Build]** **Bit-indexed BOOL writes** — present in: Kepware, TF6100. Why: we throw `NotSupportedException` (`AdsTwinCATClient.cs:99-100`); commercial drivers do read-modify-write or use `ADSIGRP_SYM_VALBYNAME` with the `.N` syntax the runtime supports for some primitives.
- **[Build]** **Multi-dim / whole-array reads** — present in: Kepware, TF6100. Why: we parse `Matrix[1,2]` element-by-element but never read the array in one ADS call; sized-array marshalling is in `TwinCAT.Ads` but unused here.
- **[Build]** **Int64 fidelity** — present in: TF6100, Ignition. Why: `LInt`/`ULInt` map to `DriverDataType.Int32` (`TwinCATDataType.ToDriverDataType` line 40 with explicit "matches Int64 gap" comment) — silent precision loss above 2^31.
- **[Build]** **TIME / DATE / DT / TOD as native OPC UA types** — present in: TF6100 (DateTime/Duration), Kepware. Why: we marshal all four as raw `UDINT` (`AdsTwinCATClient.cs:278-280`) leaving timestamp interpretation to the client.
- **[Build]** **ENUM / ALIAS / REFERENCE / POINTER / INTERFACE / UNION** — present in: TF6100, Kepware (partial). Why: not in `TwinCATDataType`; symbol-mapper returns `null` and skips.
- **[Skip]** **Multi-target / multi-route AMS gateway** — present in: Kepware, Ignition (one driver instance, many devices). Why: we accept N `Devices` but each requires its own `TwinCATDeviceOptions`; no central route table, no `StaticRoutes.xml` management, no AMS-router credential handling.
- **[Skip]** **TwinCAT 3.1.4024+ Secure ADS / ADS-over-TLS** — present in: TF6100, recent TwinCAT.Ads. Why: `AdsClient.Connect` is called without secure-ADS opts; no certificate or pre-shared-key knobs in `TwinCATDriverOptions`.
- **[Skip]** **Route credential management** — present in: Kepware (route auth UI), TF6100. Why: relies entirely on the host AMS router's pre-authorized routes; we have no in-driver way to add a route or supply credentials.
- **[Skip]** **NC-axis / CNC channel / EtherCAT slave I/O surfaces** — present in: TF6100 (full NC namespace), Kepware (NC variables). Why: our system-symbol filter actively drops `Mc_*` (`TwinCATSystemSymbolFilter.cs:28`); we treat NC plumbing as noise.
- **[Skip]** **System-service ports** (`AMSPORT_R0_REALTIME=200`, `R0_TCOMSERVER=10000`, `EVENTLOG=110`) — present in: TF6100, Kepware (system data). Why: only `Devices` are PLC-runtime ports in practice; no helpers for system-service requests, run/config-mode switches, or Real-Time diagnostic counters.
- **[Build]** **Event log ingest (TwinCAT EventLogger / TC3 Eventing)** — present in: TF6100 (alarms/conditions), Ignition. Why: we don't implement `IAlarmSource`; AMS port 110 events never surface as OPC UA AC events.
- **[Skip]** **PLC RPC / method invocation (TC3 method calls via ADS)** — present in: TF6100. Why: `IWritable` is value-only; no surface for `RpcInvoke`-style method calls on FB instances.
- **[Skip]** **Per-PLC-runtime fan-out (port 851/852/853)** — partially present. Why: technically supported via separate `Devices` entries, but no helper that auto-discovers which runtimes exist on a controller via the system service.
- **[Build]** **Sub-millisecond cycle accuracy / max-delay tuning** — present in: TF6100, Kepware. Why: `NotificationSettings(OnChange, cycleMs, 0)` clamps cycle to 1 ms and sets max-delay to 0 (`AdsTwinCATClient.cs:144-145`); no per-tag override of `MaxDelay` to coalesce bursty signals.
- **[Build]** **Cycle-time / jitter / PLC-state diagnostics** — present in: TF6100, Kepware. Why: probe only checks reachability; we don't surface cycle-time, jitter, RT-state or `_AppInfo.OnlineChangeCnt` as health signals.
- **[Build]** **Online change / symbol-version invalidation** — present in: TF6100, Ignition. Why: no listener on `ADSIGRP_SYMVAL_BYHND` invalidation event; an online change silently invalidates cached handles (we have none, but adding handles needs this).
- **[Skip]** **File-system access via ADS (`ADSIGRP_FOPEN/FREAD`)** — present in: TF6100. Why: not implemented; useful for reading recipe files / log uploads without a separate transport.
### Recommendations
| # | Gap | Build? | Rationale |
|---|-----|:------:|-----------|
| 1 | ADS Sum commands | Yes | ~10x throughput for multi-thousand-tag scans; blocker at scale |
| 2 | Handle-based access (caching) | Yes | Perf; reduces per-request bytes and AMS overhead |
| 3 | STRUCT / UDT decomposition with TMC parsing | Yes | Real projects have nested UDTs we currently can't expose |
| 4 | Bit-indexed BOOL writes | Yes | Correctness; we read bits but throw on write |
| 5 | Multi-dim / whole-array reads | Yes | Perf; library supports it |
| 6 | Int64 fidelity (LInt / ULInt) | Yes | Correctness; we silently truncate |
| 7 | TIME / DATE / DT / TOD as native UA types | Yes | Correctness; raw UDINT pushes interpretation to clients |
| 8 | ENUM / ALIAS / REFERENCE / POINTER / INTERFACE / UNION | Yes | At least ENUM and ALIAS are common in real projects |
| 9 | Multi-target / multi-route AMS gateway | No | Per-device config already works |
| 10 | Secure ADS / ADS-over-TLS | No | Significant work; defer |
| 11 | Route credential management | No | Host-level AMS router responsibility |
| 12 | NC-axis / CNC channel / EtherCAT slave I/O surfaces | No | Specialty; not in target use cases |
| 13 | System-service ports | No | Niche operational tooling |
| 14 | Event log / TC3 alarms (`IAlarmSource`) | Yes | Currently no `IAlarmSource` implementation; capability gap |
| 15 | PLC RPC / method invocation | No | Niche; design-heavy |
| 16 | Per-PLC-runtime auto-discover | No | Cosmetic; manual port config works |
| 17 | Sub-millisecond max-delay tuning | Yes | Cheap; helps coalesce bursty signals |
| 18 | Cycle-time / jitter / PLC-state diagnostics | Yes | Operability; cheap given existing probe |
| 19 | Online-change / symbol-version invalidation | Yes | Required if handle caching lands (gap #2) |
| 20 | File-system access via ADS | No | Niche; out of scope |
### Notable parity (keep)
- Native `OnChange` notifications (not polling) — matches TF6100/Kepware default and is the right CPU/latency posture.
- Symbolic addressing (no manual index-group/offset arithmetic) — same DX as Kepware's TwinCAT driver.
- Live integration suite against a real runtime (TCBSD + XAR), not just mocks — better than Ignition's stock TwinCAT module which lacks bundled hardware tests.
- System-symbol filter so `Discovered/` doesn't drown the address space — Kepware ships an equivalent.
- Config-driven tag declarations as the authoritative path; `EnableControllerBrowse` is opt-in — matches "tag-import-then-curate" workflow Kepware encourages.
- AmsNetId + port modelled correctly with TC3-vs-TC2 default port awareness — matches TF6100 conventions.
### Sources
- https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_ads_intro/index.html
- https://infosys.beckhoff.com/english.php?content=../content/1033/tcadsdll2/117571083.html (Sum commands / index groups)
- https://infosys.beckhoff.com/english.php?content=../content/1033/tf6100_tc3_opcua/index.html (TF6100)
- https://github.com/Beckhoff/TF6100-OPC-UA-Sample
- https://github.com/Beckhoff/TC3-AdsClient-Csharp / `Beckhoff.TwinCAT.Ads` NuGet docs
- https://www.kepserverexlibrary.kepware.com/Beckhoff%20TwinCAT (Kepware Beckhoff TwinCAT driver manual)
- https://docs.inductiveautomation.com/docs/8.1/platform/connections/devices (Ignition device drivers)
- https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_security_management/ (Secure ADS)
- https://infosys.beckhoff.com/english.php?content=../content/1033/tc3_adsnetref/ (NotificationSettings, AdsTransMode)

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>

159
docs/v2/focas-deployment.md Normal file
View File

@@ -0,0 +1,159 @@
# FOCAS deployment guide
Operational reference for deploying the Fanuc FOCAS driver in production.
## Licence + DLL provisioning
Fanuc's FOCAS2 library is proprietary + closed-source. Two DLL variants exist:
| Variant | Bitness | OtOpcUa usage |
|---|---|---|
| **`Fwlib64.dll`** | x64 | **Default production binary.** Loaded by `Driver.FOCAS.Host` (net10.0 x64 Windows service) and by the `Driver.FOCAS.Cli` when running on an x64 server. |
| `Fwlib32.dll` | x86 | Historical — what the project was originally scaffolded against. Not used by any current binary post the 2026-04-23 Host retarget. Kept in the licence set for legacy deployments that insist on x86-only Hosts. |
Both are **licensed for this project** — this project has a valid Fanuc FOCAS developer-kit licence that grants redistribution for either variant internally.
### The DLLs now ship with the Host (2026-04-23)
As of the vendoring change, the Host csproj copies the licensed FOCAS binaries from [`vendor/fanuc/`](../../vendor/fanuc/README.md) to its build output automatically. So after a `dotnet build` / `dotnet publish`, the layout is:
```
<publish-root>\Driver.FOCAS.Host\
├── OtOpcUa.Driver.FOCAS.Host.exe
├── OtOpcUa.Driver.FOCAS.Host.dll
├── ... runtime deps ...
├── Fwlib64.dll ← master FOCAS runtime (generic x64)
├── fwlib0iD64.dll ← 0i-D series dispatch target
├── fwlib30i64.dll ← 30i / 31i / 32i series dispatch target
├── fwlibe64.dll ← Ethernet transport variant
├── fwlibNCG64.dll ← NC Guide (Fanuc PC simulator) target
└── fwlib0DN64.dll ← 0i-D Numeric-control thin variant
```
No operator step required to "drop Fwlib64.dll on PATH" anymore — the Host loads `Fwlib64.dll` via bare-name and Windows finds it in the exe's own directory first. Shipping the full set of series-specific siblings lets the Host work against any Fanuc CNC the deployment points it at; the master `Fwlib64.dll` dispatches to the right variant based on what the CNC reports during `cnc_allclibhndl3`.
The DLL loads lazily on the first `OpenSessionAsync` call. When somehow missing (deployment artefact surgery), `Fwlib64FocasBackend` returns a structured `Fwlib64DllMissing` error-code rather than crashing; the Proxy maps it to `BadCommunicationError` with a clear operator message.
### Repo confidentiality note
**The FOCAS runtime DLLs in `vendor/fanuc/` are licensed binaries — treat this repo accordingly.** Do not mirror / push / fork to any public forge without first confirming the redistribution is covered by whoever manages the Fanuc relationship. Internal / customer-licensed mirrors are fine. See [`vendor/fanuc/README.md`](../../vendor/fanuc/README.md) for the full provenance + licence context.
## Tier-C architecture recap
The FOCAS driver is **Tier-C** — out-of-process — for **blast-radius isolation**, not bitness. Fanuc's DLL has documented crash modes (network errors, malformed responses, handle-recycle bugs) that could take the main OPC UA server down if loaded in-process. Splitting the P/Invoke into a separate Host process means a Fwlib crash only loses FOCAS tags; every other driver keeps running, and the supervisor restarts the Host.
Galaxy has the same pattern but is **forced** by MXAccess's 32-bit-only COM — there's no x64 path. FOCAS would work in-process on x64 (Fwlib64 is licensed), but the blast-radius argument keeps it Tier-C anyway.
See [`implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md) for the full topology.
## Installing the Host service
Use the NSSM wrapper script:
```powershell
.\scripts\install\Install-FocasHost.ps1 `
-InstallRoot 'C:\Program Files\OtOpcUa\Driver.FOCAS.Host' `
-ServiceAccount 'OTOPCUA\svc-otopcua' `
-FocasBackend fwlib64
```
Parameters:
| Parameter | Default | Purpose |
|---|---|---|
| `-InstallRoot` | **required** | Where the Host binaries + `Fwlib64.dll` live |
| `-ServiceAccount` | **required** | Must match the main OtOpcUa server account so the named-pipe ACL allows the Proxy to connect |
| `-FocasBackend` | `fwlib64` | `fwlib64` (production), `fake` (in-memory for Tier-C pipeline smoke without a CNC), `unconfigured` (returns BadDeviceFailure for every call) |
| `-FocasSharedSecret` | auto-gen | Per-process secret passed at service start so it never touches disk |
| `-FocasPipeName` | `OtOpcUaFocas` | Named pipe the Proxy connects to |
| `-ServiceName` | `OtOpcUaFocasHost` | Windows service display name |
`fwlib32` is accepted as a legacy alias but maps to `Fwlib64FocasBackend` internally — the Host is x64 post-2026-04-23, so 32-bit-only deployments would need to rebuild + retarget.
## Configuring a FOCAS driver instance
In the Admin UI's Drivers tab, create a `DriverInstance` with `DriverType = "FOCAS"` and a JSON config of the shape:
```json
{
"Backend": "ipc",
"PipeName": "OtOpcUaFocas",
"SharedSecret": "<matches OTOPCUA_FOCAS_SECRET env var on the Host>",
"Devices": [
{ "Name": "Mill-01", "HostAddress": "focas://192.168.1.50:8193", "Series": "ThirtyOne_i" }
],
"Tags": [
{ "Name": "SpindleLoad", "DeviceName": "Mill-01", "Address": "R100", "DataType": "Int16" },
{ "Name": "CycleRunning", "DeviceName": "Mill-01", "Address": "X0.0", "DataType": "Bit" },
{ "Name": "PartCount", "DeviceName": "Mill-01", "Address": "MACRO:500", "DataType": "Float64" }
],
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 }
}
```
`Backend` selector (on the Proxy side — not to be confused with `OTOPCUA_FOCAS_BACKEND` on the Host):
| Value | Meaning |
|---|---|
| `ipc` (default) | Route through `Driver.FOCAS.Host` over the named pipe. **Production shape.** |
| `fwlib` | Direct in-process P/Invoke via `FwlibFocasClient`. Only valid on x64 servers that are willing to accept the blast-radius trade-off. |
| `unimplemented` | Throws at construction — used for scaffolding `DriverInstance` rows before the Host is deployed. |
## Smoke testing
**Without a CNC — pipeline only:**
```powershell
$env:OTOPCUA_FOCAS_BACKEND = "fake"
Start-Service OtOpcUaFocasHost
```
The `FakeFocasBackend` stores per-address values in-memory and survives read/write/subscribe exercising. Use `otopcua-focas-cli` (in-process, bypasses the Host) or the OtOpcUa server's own driver registration to exercise the pipeline.
**Version-aware fake** (Stream A of the simulator plan, shipped 2026-04-23) — set `OTOPCUA_FOCAS_SERIES` to simulate a specific Fanuc controller's capability matrix. Addresses outside the series' documented ranges get rejected with `BadOutOfRange` (matching what the real DLL returns as `EW_NUMBER` / `EW_PARAM`):
```powershell
$env:OTOPCUA_FOCAS_BACKEND = "fake"
$env:OTOPCUA_FOCAS_SERIES = "ThirtyOne_i" # or Zero_i_D / Zero_i_F / Sixteen_i / PowerMotion_i / ...
Start-Service OtOpcUaFocasHost
```
**Optional behavioural quirks**`OTOPCUA_FOCAS_QUIRKS` is a comma-separated list:
| Token | Behaviour |
|---|---|
| `EditMode` | `OpenSessionAsync` refuses sessions with `ErrorCode=EditModeActive`, mimicking a CNC in Edit mode |
| `Emergency` | `ProbeAsync` reports the session as unhealthy with `emergency-stop active` error even after a clean open — exercises the driver's probe-surfaces-non-connectivity path |
| `SlowFirstConnect[=ms]` | First `OpenSessionAsync` blocks for `ms` (default 3000) milliseconds, mimicking the 16i-series slow-first-connect — subsequent opens are fast |
| `CrashAfterCycles=N` | After `N` session opens, the `N+1`-th returns `ErrorCode=Fwlib64Crashed` — mimics the documented Fanuc handle-leak |
Example combining several:
```powershell
$env:OTOPCUA_FOCAS_QUIRKS = "EditMode,CrashAfterCycles=5,SlowFirstConnect=500"
```
Unknown tokens log a warning but don't abort startup.
**With a real CNC:**
```powershell
$env:OTOPCUA_FOCAS_BACKEND = "fwlib64"
$env:FOCAS_TRUST_WIRE = "1"
Start-Service OtOpcUaFocasHost
.\scripts\e2e\test-focas.ps1 -CncHost 192.168.1.50 -BridgeNodeId 'ns=2;s=Focas/R100'
```
Requires `Fwlib64.dll` on `PATH` alongside the Host exe.
## Observability
- Host logs: `%ProgramData%\OtOpcUa\focas-host-*.log` (Serilog daily rolling)
- Post-mortem: `%ProgramData%\OtOpcUa\focas-post-mortem.mmf` — ring buffer of the last ~1000 IPC operations, survives a Host crash so the Proxy-side supervisor can read it during respawn diagnostic
- `DriverHostStatus` rows in the central Config DB under `HostName = <configured device host>``State` transitions + Polly resilience counters surface on the Admin `/hosts` page
## Known issues
- **No public simulator** — Fanuc FOCAS has no published emulator. Lab-rig validation (a real FANUC 0i-F / 30i controller or an FDK-licenced dev rig) is the only way to confirm wire-level correctness. Tracked under task #222.
- **32-bit-only deployments unsupported** — post the 2026-04-23 retarget, running the Host as net48 x86 is not a supported mode. If you genuinely need Fwlib32-only, revert the Host csproj + Program.cs changes from that commit.
- **Handle-recycling cadence** — documented Fanuc issue where long-lived FWLIB session handles can leak inside the DLL; the Host periodically cycles them. Currently on a fixed 60-minute cadence; future config knob tracked as a post-release follow-up.

View File

@@ -0,0 +1,315 @@
# FOCAS Docker simulator — implementation plan
> **Status**: **IN PROGRESS** 2026-04-23. **Streams A + B shipped.** Stream C (real Fwlib64 wire compat) + Stream D (e2e + docs) still open — both require a Windows rig with licensed Fwlib64.dll + captured Wireshark traces. Stream B shipped the full architectural scaffold (Docker image, 9 per-series compose profiles, asyncio TCP server, handler dispatch, profile-driven range enforcement, local validation harness) — exercised end-to-end against both `thirtyone_i` and `powermotion_i` profiles.
## Goal
Close the one remaining FOCAS gap (`#222` follow-up — "wire-level live-boot against real hardware") with a hardware-free fixture that:
1. Runs in Docker, matches the per-driver fixture pattern (`docker compose up -d` in the test project).
2. Exposes the FOCAS TCP port (`8193` by default) to the host.
3. Speaks enough of the FOCAS wire protocol that **a Windows test rig running our unmodified `Driver.FOCAS.Host` + licensed `Fwlib64.dll` can open a session and exercise the 9 FWLIB functions the driver actually uses.**
4. Supports **version profiles** — one container per Fanuc series (0i-D, 0i-F, 30i, 31i, 32i, PowerMotion-i) — so driver-side range validation, error-code mapping, and per-series quirks get exercised against a server that actually behaves differently per series.
5. Plugs into the existing e2e infrastructure (`scripts/e2e/test-focas.ps1` loses the `FOCAS_TRUST_WIRE=1` gate when the fixture is up).
## Non-goals
- **Not a full FOCAS emulator.** Fanuc's FOCAS spec is closed; faithfully reproducing every function across every controller model would be a years-long project. We implement the narrow subset the driver uses (see §Protocol surface).
- **Not a CNC behavioural model.** We return plausible values for PMC/param/macro reads; we do NOT simulate axis motion, program execution, or alarm generation. The mock exists to exercise the driver's marshalling + IPC + status-code paths, not to prove the CNC behaves correctly.
- **Not a replacement for a bench CNC.** A physical controller still catches timing-dependent bugs (Fwlib-internal thread-pool exhaustion, handle-recycle pathologies, vendor-firmware quirks) that a mock can't reproduce. Mock covers ~80% of value; real-hardware smoke stays as a final gate.
## Constraint that shapes the design
`Fwlib64.dll` is a proprietary closed-source library that speaks FOCAS to the CNC. **Our driver never touches raw TCP** — it calls `cnc_allclibhndl3` / `pmc_rdpmcrng` / etc. and Fwlib encodes the wire frames internally.
This means the mock has two possible architectures:
| Option | Where the mock lives | Exercises Fwlib? |
|---|---|---|
| **A. IPC-layer fake** (already shipped as `FakeFocasBackend`) | Between `FwlibFrameHandler` and the FWLIB call | ❌ No — bypasses Fwlib entirely |
| **B. TCP wire mock** (this plan) | Listens on port 8193; Fwlib connects to it | ✅ Yes — Fwlib encodes real frames |
Option B is the only one that validates the driver's actual production wire path (driver → Host → `FwlibFocasClient``Fwlib64.dll` → TCP → mock).
**Prerequisite reading** the implementer needs before starting Option B:
- `strangesast/fwlib` on GitHub — reverse-engineered FOCAS2 Linux client, has frame-format notes
- `GalvinGao/opcua-server-fanuc` — another OSS FOCAS client with wire-format traces
- `jdegre/focas-python` (if it still exists) — previous Python FOCAS stub, starting point
- Our own `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs` — the 9-function surface we need to satisfy
## Protocol surface (what the mock must speak)
From `FwlibNative.cs`, our driver makes exactly 9 FWLIB calls:
| FWLIB function | What it does | Wire complexity |
|---|---|---|
| `cnc_allclibhndl3` | Open Ethernet handle (connect) | **High** — initial handshake, version negotiation, session state |
| `cnc_freelibhndl` | Close handle | Low |
| `pmc_rdpmcrng` | PMC range read (byte/word/long + optional bit) | **Medium** — 40-byte buffer with type-dependent layout |
| `pmc_wrpmcrng` | PMC range write | **Medium** — same buffer shape inverted |
| `cnc_rdparam` | Parameter read (axis-aware) | Medium — 32-byte buffer |
| `cnc_wrparam` | Parameter write | Medium |
| `cnc_rdmacro` | Macro variable read (value + decimal-point count) | Low |
| `cnc_wrmacro` | Macro variable write | Low |
| `cnc_statinfo` | Status info (for probe) | Low — fixed-shape response |
**Coverage target**: all 9 functions return plausible responses for the address ranges declared in each series profile. Out-of-range addresses return `EW_NUMBER` / `EW_PARAM`. Unknown PMC letters return `EW_DATA`. Session state (handle validity, unknown handle detection) is enforced.
## Version profiles
The driver has `FocasCncSeries` + `FocasCapabilityMatrix` already — we mirror that matrix into JSON profiles the mock loads at start:
```
fixture/
├── Dockerfile
├── requirements.txt
├── server/
│ ├── focas_server.py # asyncio TCP server + frame parser
│ ├── handlers/
│ │ ├── allclibhndl3.py
│ │ ├── pmc.py
│ │ ├── param.py
│ │ ├── macro.py
│ │ └── status.py
│ ├── state.py # in-memory "CNC" state
│ └── frames.py # FOCAS frame encode/decode
└── profiles/
├── zero_i_d.json
├── zero_i_f.json
├── zero_i_mf.json
├── zero_i_tf.json
├── sixteen_i.json
├── thirty_i.json
├── thirtyone_i.json
├── thirtytwo_i.json
└── powermotion_i.json
```
Each profile captures:
```json
{
"series": "ThirtyOne_i",
"api_version": "0x30",
"pmc_ranges": {
"X": [0, 127], "Y": [0, 127], "F": [0, 767], "G": [0, 767],
"R": [0, 1499], "D": [0, 2999], "C": [0, 199], "K": [0, 31],
"A": [0, 24], "T": [0, 79], "E": [0, 9999]
},
"param_ranges": [[1000, 9999], [10000, 15999]],
"macro_range": [100, 999],
"extended_macros": false,
"axes": 3,
"quirks": {
"crash_after_handle_cycles": null,
"edit_mode_rejects_connection": false,
"allclibhndl3_blocks_during_alarm": false,
"param_bit_index_max": 7
},
"alarm_default": false,
"emergency_default": false
}
```
**Differences that actually matter** for driver coverage:
| Series | Meaningful difference vs baseline |
|---|---|
| 0i-D / 0i-F / 0i-MF / 0i-TF | PMC range narrower; no E-relay; macro range `100-999` strict |
| 16i | Older Fwlib version; `cnc_allclibhndl3` extra-slow on first connect (artificial delay in mock) |
| 30i | Full PMC range; extended macros (`#10000+`) supported |
| 31i / 32i | 5-axis; larger parameter ranges |
| PowerMotion-i | No PMC `T` timer; motion-only controller quirks |
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Windows test rig (net10.0 x64) │
│ │
│ FocasDriver ──► FwlibFocasClient ──► Fwlib64.dll ──► TCP ──┐ │
│ (real P/Invoke) │ │
└─────────────────────────────────────────────────────────────┼───┘
port 8193 │
┌─────────────────────────────────────────────────────────────────┐
│ Docker container: otopcua-focas-sim-{series} │
│ │
│ Python asyncio TCP server │
│ ├─ frames.py: parse + encode FOCAS frames │
│ ├─ handlers/: one module per FWLIB function │
│ ├─ state.py: per-session handle registry + simulated memory │
│ └─ profiles/{series}.json: range + quirk table loaded at │
│ boot via env var OTOPCUA_FOCAS_ │
│ PROFILE=thirtyone_i │
└─────────────────────────────────────────────────────────────────┘
```
Python choice rationale: the existing OSS FOCAS implementations are Python-first; asyncio's `StreamReader`/`StreamWriter` maps cleanly to FOCAS's length-prefixed frame model; one Dockerfile covers every profile because profile-switching is an env-var.
`docker-compose.yml` exposes one service per profile as a `--profile`:
```yaml
services:
focas-thirtyone:
profiles: ["thirtyone"]
image: otopcua-focas-sim:latest
environment: { OTOPCUA_FOCAS_PROFILE: "thirtyone_i" }
ports: ["8193:8193"]
focas-zerod:
profiles: ["zerod"]
image: otopcua-focas-sim:latest
environment: { OTOPCUA_FOCAS_PROFILE: "zero_i_d" }
ports: ["8193:8193"]
# ... one per supported series ...
```
Users pick a profile with `docker compose --profile thirtyone up -d`. Only one profile runs at a time (port collision on 8193) — matching the other driver fixtures' single-image pattern.
## Delivery plan — three streams
### Stream A — Version-aware fake backend (C#, 2-3 days) — ✅ **SHIPPED 2026-04-23**
**What landed**:
- `FakeFocasBackend` gained a second ctor `(FocasCncSeries series, FakeFocasBackendQuirks? quirks)`; default ctor preserves the pre-Stream-A permissive behaviour.
- `ValidateAddress` delegates to the existing `FocasCapabilityMatrix.Validate` so mock + driver share one source of truth. Out-of-range reads/writes/PMC-bit-writes return `BadOutOfRange` (0x803C0000 — matching what the real driver maps `EW_NUMBER`/`EW_PARAM` to).
- `FakeFocasBackendQuirks` record carries four opt-in quirks: `EditModeRejectsConnection`, `CrashAfterHandleCycles`, `SlowFirstConnectDelay`, `EmergencyAtStartup`.
- `Program.cs` reads `OTOPCUA_FOCAS_SERIES` (case-insensitive FocasCncSeries enum value) + `OTOPCUA_FOCAS_QUIRKS` (comma-separated token list: `EditMode`, `Emergency`, `SlowFirstConnect[=ms]`, `CrashAfterCycles=N`). Unknown tokens log-and-ignore. Values surface in Host log at startup.
- 19 new tests in `FakeFocasBackendSeriesTests.cs` covering: Unknown-permissive baseline, Zero_i_D macro rejection, ThirtyOne_i extended-macro acceptance, PowerMotion_i T-timer rejection, Write+PmcBitWrite parallel rejection, all four quirks, + 8 theory cases for the env-var parser.
**Deliverable shipped**:
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs` — extended
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs``BuildFakeBackend` local fn + `ParseFakeQuirks` helper
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/FakeFocasBackendSeriesTests.cs` — new, 19 tests
- 38/38 Host tests green post-Stream-A.
### Stream B — Python FOCAS TCP server (scaffold) — ✅ **SHIPPED 2026-04-23**
**What landed** under `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/`:
- `Dockerfile` — Python 3.12-slim image; stdlib-only, no external deps
- `docker-compose.yml` — 9 `--profile` entries, one per Fanuc series (`thirtyone`, `thirtytwo`, `thirty`, `sixteen`, `zerod`, `zerof`, `zeromf`, `zerotf`, `powermotion`). All share one image + one port (8193).
- `server/focas_server.py` — asyncio entry point, per-connection session loop, graceful-shutdown signal handling
- `server/frames.py` — length-prefixed frame codec (scaffold — see Stream C note below)
- `server/state.py` — per-session handle registry + in-memory PMC/param/macro dictionaries
- `server/profile.py` — JSON profile loader
- `server/handlers/` — one module per FWLIB function (9 total): open/close, PMC read/write, param read/write, macro read/write, statinfo. Profile-driven range validation; error responses use a `FLAG_ERROR` bit on the response header.
- `profiles/*.json` — 9 series profiles mirroring `FocasCapabilityMatrix`. Quirks (`slow_first_connect_ms`, `alarm_default`, `emergency_default`, `crash_after_handle_cycles`, `edit_mode_rejects_connection`) declared per profile.
- `validate_harness.py` — scaffold-protocol TCP client that opens a session, round-trips a macro, triggers range-rejection, asserts the expected error reasons surface.
- `README.md` — operator-facing usage + Stream C next-steps checklist.
**Exit criterion met**: validated end-to-end against two profiles (`thirtyone_i`, `powermotion_i`) via the local harness. Session handshake → statinfo → macro round-trip → out-of-range rejection → PMC round-trip → bad-letter rejection → clean close — all PASS. Profile-switching confirmed working: 31i API 0x0030 → PowerMotion 0x0040, macro range [0,99999]→[0,999], letter set {A,C,D,E,F,G,K,M,R,T,X,Y}→{D,R,X,Y}.
**⚠️ The wire *framing* is a scaffold — NOT Fwlib64-compatible yet.** `server/frames.py` uses a plausible length-prefixed framing (big-endian header: uint32 length, uint16 function_id, uint16 flags) that satisfies the harness but has never been validated against the real Fanuc DLL. Stream C is the iterative refinement cycle where a Windows rig drives that convergence.
**The response payload shapes inside those frames ARE authoritative** (refined 2026-04-23 after `fwlib32.h` review):
- `ODBM` (macro read) = 10 bytes: `short datano, short dummy, int32 mcr_val, short dec_val`
- `ODBST` (statinfo) = 18 bytes: 9 × `short` (dummy/tmmode/aut/run/motion/mstb/emergency/alarm/edit)
- `IODBPSD` (param read) = 36 bytes: `short datano, short type, bytes[32]` (union = 8 axes × 4 bytes)
- `IODBPMC` (PMC range read) = 48 bytes: `short type_a, short type_d, uint16 datano_s, uint16 datano_e, bytes[40]`
Validate harness asserts exact byte sizes + header field round-trip. When Stream C's Wireshark traces arrive, the payload layer should already match — only framing needs iteration.
See [`focas-wire-protocol.md`](focas-wire-protocol.md) for the authoritative-vs-guessed breakdown.
**C# integration test scaffold** also shipped (`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`) — `FocasSimFixture` probes port 8193 + skips when the container's down; three smoke tests pass against a running container (TCP reachability, clean connect-close, profile parsing). A `Series/WireCompatGatedTests.cs` skeleton gates Fwlib64-dependent tests behind `OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1`, ready for Stream C activation.
### Stream C — FWLIB compat + version profiles (2-3 weeks) — **blocked on Windows rig + Wireshark traces**
See `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md` §"Stream C — what's required to reach wire compatibility" for the concrete implementer checklist.
**Goal**: real Fwlib64.dll running on a Windows test rig can open a session against the mock and round-trip the 9 FWLIB calls our driver makes.
Sub-tasks:
1. **Handshake** (`handlers/allclibhndl3.py`) — the hardest piece. FOCAS session open negotiates protocol version + controller type. Incorrect negotiation → Fwlib disconnects. Start from `strangesast/fwlib`'s handshake trace.
2. **PMC read/write** (`handlers/pmc.py`) — 40-byte buffer with type-dependent layout. Must match `FwlibNative.IODBPMC` struct layout exactly. Implement per-profile range checks.
3. **Parameter read/write** (`handlers/param.py`) — 32-byte axis-aware buffer. Similar to PMC but simpler (no sub-address bit indexing beyond `param_bit_index_max`).
4. **Macro read/write** (`handlers/macro.py`) — straightforward; value + decimal-point count as `ODBM`.
5. **Status info** (`handlers/status.py`) — fixed `ODBST` shape; profile declares defaults for `Aut` / `Run` / `Motion` / `Alarm`.
6. **State management** (`server/state.py`) — per-session handle registry, in-memory PMC/param/macro dictionaries, persistent across one session, reset on session close.
7. **Profile loader** — reads `OTOPCUA_FOCAS_PROFILE` env var, loads matching JSON, injects into handlers.
8. **Windows validation rig** — one-time setup: a Windows VM (or dev box) with licensed `Fwlib64.dll` + a tiny test driver that calls the 9 FWLIB functions + asserts round-trip. This is the first live-wire validation the plan asks for.
9. **Per-series test matrix**`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` new project, one test class per series, each class's `[Fact]` runs against that profile's container.
**Exit criterion**: live Fwlib64.dll on a Windows rig opens a session, reads + writes across all 9 FWLIB functions, against each of the 9 profiles. Integration test suite green.
### Stream D — e2e integration + doc close-out (1-2 days)
- Update `scripts/e2e/test-focas.ps1` to accept `-ProfileName` and skip `FOCAS_TRUST_WIRE` gate when the matching container is up.
- Add the FOCAS simulator to `docs/v2/test-data-sources.md` + `docs/drivers/FOCAS-Test-Fixture.md` (flip the "hardware-gated" caveat to "fixture or hardware").
- Update `exit-gate-phase-3.md` — final FOCAS deferral closes.
## Test integration
The new project `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` mirrors `Driver.OpcUaClient.IntegrationTests`:
```
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/
├── Docker/
│ ├── docker-compose.yml # references the 9 series profiles
│ ├── Dockerfile # Python image
│ ├── requirements.txt
│ ├── server/
│ └── profiles/
├── FocasSimFixture.cs # probes 8193 at collection init, skips if down
├── FocasSimSeriesProfile.cs # test-side mirror of the JSON profile
└── Series/
├── ThirtyOneITests.cs
├── ZeroIDTests.cs
└── ... one file per series ...
```
The existing `FocasDocker`-less skip pattern applies: if the container isn't running, tests skip with a clear message pointing at `docker compose up -d`. Matches Modbus / S7 / OpcUaClient.
## Risks + mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| FOCAS wire protocol is more complex than the OSS traces suggest → Stream C slips weeks | **Medium** | High | Stream A delivers 70% value with zero protocol risk. If Stream C stalls, ship A + schedule C as a follow-up. |
| Fwlib64.dll version differs from what `strangesast/fwlib` reverse-engineered → handshake fails | Medium | High | Capture Wireshark trace of a real CNC session against our actual licensed Fwlib64 version before coding. One-time investment, catches drift early. |
| Profile differences that matter at the wire level aren't captured in `FocasCapabilityMatrix` | Medium | Medium | Stream C exit criterion includes validating each profile against live Fwlib — any mismatch is a profile-table bug we fix then. |
| Docker container startup time breaks PR-CI budget | Low | Low | Each profile is one Python container + profile JSON — sub-5s cold start. Matches opc-plc. |
| Windows validation rig availability blocks Stream C | Medium | High | Use the existing TCBSD-class approach: a dedicated ESXi VM with Windows + licensed Fwlib64.dll, provisioned once, shared by the team. Cost ~1 dev-day to set up; unblocks all future FOCAS work forever. |
| Fanuc licence audit surfaces our mock as an "unlicensed FOCAS implementation" | **Low** | **High** | The mock doesn't ship the Fanuc DLL or reproduce any of Fanuc's code. Reverse-engineered wire formats from OSS research are fair use; the mock is our code. Consult legal before open-sourcing, not before internal use. |
## Timeline estimate
Assuming one dev full-time:
| Stream | Duration | Dependencies |
|---|---|---|
| A — Version-aware fake backend | 2-3 days | none |
| B — TCP server scaffold | 1 week | Windows rig not required yet |
| C — FWLIB compat + profiles | 2-3 weeks | Windows rig with Fwlib64 + Wireshark trace |
| D — e2e + docs | 1-2 days | C done |
**Total**: ~4-5 weeks to full coverage. Ship A immediately (independent value), start C in parallel with Windows-rig setup.
## Exit criteria (what closes #222)
- [ ] All 9 series profiles containerized + pass startup health check
- [ ] Live Fwlib64.dll round-trips all 9 FWLIB calls against every profile (Stream C validation rig)
- [ ] Per-series integration test suite green in CI
- [ ] `test-focas.ps1` runs end-to-end against the simulator without `FOCAS_TRUST_WIRE=1`
- [ ] Docs updated: `FOCAS-Test-Fixture.md` flipped from "hardware-only" to "fixture or hardware"
- [ ] One live-CNC smoke still runs during v2 release readiness, as a belt-and-braces final check
## Open questions
1. **Licence clarity**: is reverse-engineered FOCAS2 wire-format documentation (from `strangesast/fwlib` etc.) compatible with our Fanuc FOCAS developer-kit licence? Legal check required before starting Stream C.
2. **Windows rig**: do we dedicate an existing VM (like the TCBSD box) or provision a new one? Cost difference is small; decision affects who owns maintenance.
3. **Profile source of truth**: if `FocasCapabilityMatrix.cs` and `profiles/*.json` ever disagree, which wins? Proposal: profiles win (wire behavior is authoritative), driver's matrix is regenerated from profiles as a build step.
4. **Alarm events**: the driver doesn't currently use `cnc_rdalmmsg2` / alarm subscription, so the mock doesn't need to simulate alarms beyond the `statinfo.Alarm` flag. If we add `IAlarmSource` to FOCAS later, Stream C expands.
## References
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs` — 9-function P/Invoke surface the mock must satisfy
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs` — per-series range tables (profile seed data)
- `docs/v2/focas-version-matrix.md` — human-readable version matrix the profiles mirror
- `docs/drivers/FOCAS-Test-Fixture.md` — current test-fixture doc (flips post-Stream-D)
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/` — pattern this plan mirrors for the Docker compose + fixture-skip shape
- `strangesast/fwlib` (GitHub, OSS) — primary FOCAS wire-format reverse-engineering reference

87
followup.md Normal file
View File

@@ -0,0 +1,87 @@
# Follow-ups from `auto/driver-gaps` queue (PRs #225#316, 92 merged)
Captured 2026-04-26 after the plan-execution queue drained. Organised by category.
## Wrapper / library-version-blocked (waiting on upstream)
| Driver | PR | Blocker | Resolution path |
|---|---|---|---|
| AbCip | abcip-3.1 | libplctag.NET 1.5.2 doesn't expose `connection_size` | Reflection fallback ships; remove when wrapper publishes the property |
| AbCip | abcip-3.2 | libplctag.NET 1.5.x has no public instance-ID knob | Wire stays Symbolic regardless of mode; flip when wrapper exposes it |
| AbCip | abcip-3.3 | libplctag.NET wire-level multi-service-packet bundling not exposed | Planner ships correct; runtime currently issues N reads. Switch when wrapper bundles |
| S7 | s7-e1 | S7netplus 0.20 has no public `ReadSzlAsync` (request builder is internal) | Parser tested + cached; `BadNotSupported` until S7netplus exposes it or we add raw S7comm SZL-PDU helper |
| S7 | s7-e2 | S7netplus 0.20 doesn't expose `SendPassword` | `IS7PlcAuthGate` reflection probe; logs warning, no exception. Flip when library exposes it |
| TwinCAT | twincat-2.2 | Bulk Sum path stays on symbolic | Phase-2 perf sweep follow-up to switch bulk to handle-based |
| TwinCAT | twincat-5.1 | Beckhoff doesn't ship a managed `TcEventLogger` wrapper | Gate seam ships; production `AdsTwinCATAlarmGate` binary decoder against `ADSIGRP_TCEVENTLOG_ALARMS` is the next chunk of work |
## Fixture / simulator gaps
### focas-mock simulator doesn't exist
- Blocks integration tests for: f3a (alarm history ring-buffer + `mock_patch_alarmhistory`), f4b (`mock_set_unlock_state`, `mock_get_last_write`), f4c (`pmc_wrpmcrng` handler), f4d (`cnc_wrunlockparam` + `mock_set_password`), f5a (`mock_simulate_cycle_completion`).
- No FOCAS IntegrationTests project exists yet — it needs to be created when the mock lands.
### opc-plc fixture upgrades
- **opcuaclient-10**: `TriggerModelChangeAsync` is a stub. Live HTTP-driven model-change verification deferred. Tests use an inject seam.
- **opcuaclient-11**: `opc-plc-rc` Docker fixture session-open assertion (gated `OPCUACLIENT_TOPOLOGY_TRIGGER_CMD` / `OPCUA_RC_SIM`).
- **opcuaclient-12**: opc-plc `--alm` fixture run for HistoryRead Events (waiting for fixture image upgrade).
- **opcuaclient-13**: opc-plc historian-sim wire-level sweep for the 25 new aggregates (only ~5 likely honoured today).
- **opcuaclient-14**: Two-container failover smoke against opc-plc + opc-plc-secondary on the live fixture.
### AbCip HSBY paired-fixture
- **abcip-5.1/5.2**: `hsby-mux` Python sidecar is a stub; the patched `ab_server` image and live role-flip integration test are gated until that stabilises.
### AbLegacy auto-demote fixture
- **ablegacy-12**: `slc500-faulty` is a commented compose placeholder; tests use the `127.0.0.1:1` ECONNREFUSED trick. Real refusing-proxy fixture is follow-up.
### TCBSD TwinCAT project
- twincat-2.1, 3.1, 3.2, 4.1, 5.1 added new fixture stub files that need to be imported into the actual TwinCAT XAE project before `[TwinCATFact]` integration tests can exercise them:
- `PLC/GVLs/GVL_Perf.TcGVL` + `PLC/POUs/FB_PerfChurn.TcPOU` (twincat-2.1)
- `PLC/DUTs/ST_NestedFlags.TcDUT`, `ST_RecursiveCap.TcDUT`, `ST_AlarmRecord.TcDUT` (twincat-4.1)
- `PLC/GVLs/GVL_Plant.TcGVL` extensions (twincat-4.1)
- `PLC/GVLs/GVL_Alarms.TcGVL` + `PLC/POUs/FB_AlarmHarness.TcPOU` (twincat-5.1)
### Snap7 round-trip tests
- s7-d1 (TIA CSV), s7-d2 (UDT fan-out), s7-d3 (instance-DB), s7-c1 (negotiated PDU), s7-c3 (scan groups), s7-c4 (deadband) integration tests are build-only until run against the live Snap7 fixture.
## Live-firmware / hardware verification
- **s7-c2** — hardened S7-1500 with non-PG TSAP modes (gated `--with-real-plc`).
- **s7-c5** — hardened PLC with PUT/GET disabled (currently only Snap7 happy-path tested).
- **s7-f** — manual checklist: toggle Optimized block access in TIA + Track 3 OPC UA bridge verification.
- **ablegacy-13** — DH+ via real 1756-DHRIO + PLC-5. No Docker fixture possible.
- **twincat-2.1 perf-tier** — `Driver_sum_read_1000_tags_beats_loop_baseline_by_5x` gated `TWINCAT_PERF=1`.
- **twincat-2.3** — symbol-version online-change drill (`TWINCAT_MANUAL_ONLINE_CHANGE=1`).
- **focas-f4b/c/d** — live CNC parameter / macro / PMC writes + password-protected CNC.
## Cross-driver / ecosystem
- **opcuaclient-12** — Galaxy A&E projection currently keeps the fixed-field `ReadEventsAsync(sourceName, ...)` overload; richer SelectClause-aware projection on the Galaxy A&E log is best-effort future work.
- **per-driver plan files don't exist** — opcuaclient-12 cross-driver `IHistoryProvider` heads-up went into doc-comments instead. If anyone adds per-driver plan files later, the heads-up note belongs in each.
## Pre-existing red-build issues (NOT touched, will block solution-level CI)
- **NU1902 OpenTelemetry warning-as-error in Admin** — predates the queue.
- **`Server/Phase7/DriverSubscriptionBridge.cs` cref ambiguity** — predates the queue.
Both must be fixed before solution-level CI can pass on the merged-up `task-galaxy-e2e`.
## Integration-branch merge
- **`auto/driver-gaps` has 92 stacked PRs** vs `task-galaxy-e2e`. Final merge needs a careful single review — likely staged or one big PR — and will collide with whatever has landed on `task-galaxy-e2e` in parallel.
## Plan-vs-reality deltas (informational; nothing to chase)
- **focas-f4a/b/c/d** — Plan referenced doc lines that had already been removed in prior evolution (FOCAS.md "intentionally returns BadNotWritable" callout; FOCAS-Test-Fixture.md alarms-not-covered caveat).
- **opcuaclient-12** — Repo has no per-driver plan files for abcip / ablegacy / s7 / twincat — heads-up went into IHistoryProvider doc-comments instead.
- **twincat-4.1** — `docs/v3/twincat-backlog.md` doesn't exist; UDT-gap-removal item N/A.
## Highest-leverage cleanup once upstream catches up
When the upstream library bumps, these reflection / `BadNotSupported` paths simplify to direct calls:
- **abcip-3.1**: remove reflection fallback in `LibplctagTagRuntime.TrySetIntAttribute(connection_size, ...)`.
- **abcip-3.2**: remove `LibplctagTagRuntime.TrySetLogicalAddressing` reflection.
- **abcip-3.3**: switch `MultiPacket` runtime from N-reads to true wire-bundle.
- **s7-e1**: replace `S7NetSzlReader.ReadAsync` returning null with real `Plc.ReadSzlAsync`.
- **s7-e2**: replace `ReflectionS7PlcAuthGate` warning path with direct `Plc.SendPasswordAsync` call.
- **twincat-5.1**: ship `AdsTwinCATAlarmGate` binary decoder.

View File

@@ -11,7 +11,7 @@
of this test use `otopcua-cli` against two different endpoints: of this test use `otopcua-cli` against two different endpoints:
remote = the upstream OPC UA server the driver connects to (opc-plc fixture remote = the upstream OPC UA server the driver connects to (opc-plc fixture
by default, opc.tcp://localhost:50000) by default, opc.tcp://10.100.0.35:50000)
local = the OtOpcUa server itself, which mirrors remote nodes through the local = the OtOpcUa server itself, which mirrors remote nodes through the
OpcUaClient driver instance (opc.tcp://localhost:4840) OpcUaClient driver instance (opc.tcp://localhost:4840)
@@ -72,7 +72,7 @@
.PARAMETER RemoteUrl .PARAMETER RemoteUrl
Upstream OPC UA server endpoint (the server the driver connects to). Upstream OPC UA server endpoint (the server the driver connects to).
Default matches the opc-plc Docker fixture — opc.tcp://localhost:50000. Default matches the opc-plc Docker fixture — opc.tcp://10.100.0.35:50000.
.PARAMETER OpcUaUrl .PARAMETER OpcUaUrl
Local OtOpcUa server endpoint. Default opc.tcp://localhost:4840. Local OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
@@ -146,7 +146,7 @@
#> #>
param( param(
[string]$RemoteUrl = "opc.tcp://localhost:50000", [string]$RemoteUrl = "opc.tcp://10.100.0.35:50000",
[string]$OpcUaUrl = "opc.tcp://localhost:4840", [string]$OpcUaUrl = "opc.tcp://localhost:4840",
[string]$RemoteNodeId = "ns=3;s=FastUInt1", [string]$RemoteNodeId = "ns=3;s=FastUInt1",
[Parameter(Mandatory)] [string]$BridgeNodeId, [Parameter(Mandatory)] [string]$BridgeNodeId,

View File

@@ -0,0 +1,21 @@
{
"auto-managed": 10,
"cross-driver": 14,
"driver/abcip": 13,
"driver/ablegacy": 16,
"driver/focas": 11,
"driver/opcuaclient": 12,
"driver/s7": 19,
"driver/twincat": 17,
"phase/1": 8,
"phase/2": 7,
"phase/3": 6,
"phase/4": 5,
"phase/5": 4,
"phase/6": 3,
"queue/blocked": 2,
"queue/done": 15,
"queue/failed": 9,
"queue/in-progress": 1,
"queue/queued": 18
}

View File

@@ -0,0 +1,320 @@
- id: twincat-1.1
driver: twincat
phase: 1
plan_pr_id: "1.1"
title: "TwinCAT — Int64 fidelity for LINT/ULINT"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Map LInt/ULInt to DriverDataType.Int64 instead of silently truncating to Int32.
The TwinCATDataType.cs:40 truncation comment "matches Int64 gap" is removed and
MapToClrType already returns long/ulong, so the wire-level read returns the
correct boxed types. May add Int64 to Core.Abstractions DriverDataType enum if
missing. Closes a long-standing fixture caveat noted in the test suite.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs"
- "src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Primitives.TcGVL"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: S
deps: []
cross_driver: false
notes: "Hardware-gated via TWINCAT_TARGET_NETID; no e2e change to test-twincat.ps1."
- id: twincat-1.2
driver: twincat
phase: 1
plan_pr_id: "1.2"
title: "TwinCAT — TIME/DATE/DT/TOD as native UA types"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Stop marshalling IEC TIME/DATE/DT/TOD as raw UDINT and convert to native UA
Duration/DateTime types via post-processing in ReadValueAsync, ConvertForWrite,
and OnAdsNotificationEx. May expose missing Duration in DriverDataType. CLI
syntax updates so users write ISO-8601 / IEC literals instead of numeric raw
values.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Primitives.TcGVL"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: M
deps: []
cross_driver: false
notes: "Hardware-gated via TWINCAT_TARGET_NETID. May add Duration to DriverDataType enum."
- id: twincat-1.3
driver: twincat
phase: 1
plan_pr_id: "1.3"
title: "TwinCAT — Bit-indexed BOOL writes (read-modify-write)"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Replace the NotSupportedException at AdsTwinCATClient.cs:99 with read-modify-write
on the parent word, serializing concurrent bit writes to the same parent via a
keyed SemaphoreSlim. Closes referenced task #181. CLI gains an example and the
fixture caveat in the bugs-caught list updates to note writes now work.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture: []
e2e: []
effort: S
deps: []
cross_driver: false
notes: "Reuses GVL_Primitives.vWord (0xBEEF) — no fixture schema change."
- id: twincat-1.4
driver: twincat
phase: 1
plan_pr_id: "1.4"
title: "TwinCAT — Multi-dim and whole-array reads"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Expand ReadValueAsync/WriteValueAsync to handle whole-array reads in a single
AdsClient call rather than element-by-element. Surface IsArray + ArrayDimensions
on TwinCATTagDefinition and through DriverAttributeInfo from DiscoverAsync. Sets
up the array-shape plumbing the rest of the driver needs.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Arrays.TcGVL"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: M
deps: []
cross_driver: false
notes: "Hardware-gated via TWINCAT_TARGET_NETID. New 5x5 aReal2D seed with deterministic pattern."
- id: twincat-1.5
driver: twincat
phase: 1
plan_pr_id: "1.5"
title: "TwinCAT — ENUM and ALIAS at discovery"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
MapSymbolTypeName currently returns null for non-atomic types, dropping ENUM and
ALIAS symbols silently. Switch to inspecting symbol.DataType + Category from
TwinCAT.TypeSystem so DataTypeCategory.Enum walks EnumValues and Alias resolves
to base atomic recursively. Surface enum members for later EnumStrings rendering.
POINTER/REFERENCE/INTERFACE/UNION explicitly out of scope.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: M
deps: []
cross_driver: false
notes: "Reuses existing GVL_Enums + DUTs; only README integration-test contract entry added."
- id: twincat-2.1
driver: twincat
phase: 2
plan_pr_id: "2.1"
title: "TwinCAT — ADS Sum-read / Sum-write"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Replace per-tag ReadValueAsync loops with Beckhoff's ADS Sum commands
(IndexGroup 0xF080-0xF084) via SumSymbolRead/SumSymbolWrite to batch N
reads/writes per AMS request. Bucket fullReferences by DeviceHostAddress and
expose a new ReadValuesAsync surface on ITwinCATClient. Targets ~10x throughput
on multi-thousand-tag scans; perf-tier test gated behind TWINCAT_PERF=1.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs"
docs:
- "docs/v3/twincat-backlog.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Perf.TcGVL"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: L
deps: []
cross_driver: false
notes: "Perf test gated behind TWINCAT_PERF=1 plus TWINCAT_TARGET_NETID; new FB_PerfChurn POU."
- id: twincat-2.2
driver: twincat
phase: 2
plan_pr_id: "2.2"
title: "TwinCAT — Handle-based access with caching"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Cache CreateVariableHandleAsync results so per-read overhead drops to
read-by-handle (4-byte index vs N-byte symbol path). On
DeviceSymbolVersionInvalid (0x710) evict and retry once. Clear cache on
AdsClient reconnect until the symbol-version listener (PR 2.3) ships. Dispose
path calls DeleteVariableHandleAsync for cached handles.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture: []
e2e: []
effort: M
deps: []
cross_driver: false
notes: "Combines with PR 2.1 for sum-read-by-handle. Reuses GVL_Perf.aTags."
- id: twincat-2.3
driver: twincat
phase: 2
plan_pr_id: "2.3"
title: "TwinCAT — Symbol-version invalidation listener"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Register an AddDeviceNotificationAsync on the symbol-version index group
(AdsReservedIndexGroup.SymbolVersion 0xF008) so the handle cache from PR 2.2
is wiped on online-change bumps. Initial integration test gated as
requires-manual-online-change until automation lands. Resolves open question
(c) confirming the v6 enum constant.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
docs:
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: M
deps: ["twincat-2.2"]
cross_driver: false
notes: "Hardware-gated via TWINCAT_TARGET_NETID; manual online-change drill documented in README."
- id: twincat-3.1
driver: twincat
phase: 3
plan_pr_id: "3.1"
title: "TwinCAT — Per-tag MaxDelay tuning"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Surface NotificationSettings MaxDelay as a per-tag option (default 0 to
preserve current behavior). Plumb int? MaxDelayMs through TwinCATTagDefinition,
SubscribeAsync, and AddNotificationAsync. Coalesces high-frequency PLC signals
so the OPC UA subscription queue stops flooding under bursty change rates.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: S
deps: []
cross_driver: false
notes: "Reuses GVL_Fixture.nCounter as 100 Hz driver. Hardware-gated via TWINCAT_TARGET_NETID."
- id: twincat-3.2
driver: twincat
phase: 3
plan_pr_id: "3.2"
title: "TwinCAT — Cycle-time / jitter / PLC-state diagnostics"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Augment the probe loop to read _AppInfo.OnlineChangeCnt/AppName and
_TaskInfo[1].CycleTime/LastExecTime, surface as TwinCATDeviceDiagnostics on
DeviceState, and emit through IDriverDiagnostics (cross-driver surface from
Modbus task #154). Read system symbols directly without going through the user
browse filter.
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs"
docs:
- "docs/drivers/TwinCAT-Test-Fixture.md"
- "docs/Driver.TwinCAT.Cli.md"
- "docs/v3/twincat-backlog.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: M
deps: []
cross_driver: true
notes: "Reuses IDriverDiagnostics from Modbus task #154. Hardware-gated via TWINCAT_TARGET_NETID."
- id: twincat-4.1
driver: twincat
phase: 4
plan_pr_id: "4.1"
title: "TwinCAT — Nested UDT browse via online type walker"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Largest single piece of work. Recurse BrowseSymbolsAsync into IStructType.SubItems
yielding one TwinCATDiscoveredSymbol per leaf with dotted instance paths. Expand
arrays-of-structs up to a configurable bound (default 1024). Add a pure
TwinCATTypeWalker helper. Folds recursed structure into Discovered/ folder tree.
Online runtime path only — TMC offline parsing deferred per open question (a).
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeWalker.cs"
docs:
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
- "docs/v3/twincat-backlog.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_NestedFlags.TcDUT"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_RecursiveCap.TcDUT"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/DUTs/ST_AlarmRecord.TcDUT"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e: []
effort: L
deps: ["twincat-1.5"]
cross_driver: false
notes: "Hardware-gated via TWINCAT_TARGET_NETID. PR 1.4 helpful but not blocking."
- id: twincat-5.1
driver: twincat
phase: 5
plan_pr_id: "5.1"
title: "TwinCAT — IAlarmSource via TC3 EventLogger"
plan_anchor: "docs/plans/twincat-plan.md"
summary: |
Implement IAlarmSource over TcEventLogger on AMS port 110 so PLC alarms
surface as OPC UA AC events. Begins with a one-day spike (open question (b))
documented in docs/v3/twincat-eventlogger-spike.md to determine if a managed
wrapper exists or if we hit AMS port 110 directly via a secondary AdsClient
+ AddDeviceNotificationAsync on the alarm-list index group. Gated by new
EnableAlarms option (default false).
files:
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs"
- "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs"
docs:
- "docs/drivers/TwinCAT.md"
- "docs/v3/twincat-eventlogger-spike.md"
- "docs/Driver.TwinCAT.Cli.md"
- "docs/drivers/TwinCAT-Test-Fixture.md"
fixture:
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/POUs/FB_AlarmHarness.TcPOU"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/PLC/GVLs/GVL_Alarms.TcGVL"
- "tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md"
e2e:
- "scripts/e2e/test-twincat.ps1"
effort: L
deps: []
cross_driver: false
notes: "Hardware-gated via TWINCAT_TARGET_NETID. Spike-first; e2e Test-AlarmRoundTrip likely deferred to follow-up."

36
scripts/queue/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Plan-execution queue
Gitea-backed work queue that drives the per-driver implementation plans (`docs/plans/*-plan.md`) to completion in **Mode B** (autonomous: auto-merges into the `auto/driver-gaps` integration branch when build+tests pass).
## Pieces
- `pr-manifest.yaml` — canonical list of every PR across all six plans.
- `setup-labels.sh` — idempotently creates the queue labels in Gitea.
- `file-issues.sh` — files one Gitea issue per manifest entry (idempotent — skips ids that already exist).
- `next-pr.sh` — picks the next eligible queue issue (queued, blockers all done) as JSON.
- `start-pr.sh ISSUE BRANCH` — flips queued → in-progress and creates the branch off `auto/driver-gaps`.
- `open-pr.sh ISSUE BRANCH TITLE BODY_FILE` — opens a PR from BRANCH into `auto/driver-gaps`.
- `merge-pr.sh PR` — merges a PR with branch-delete (Mode B).
- `finish-pr.sh ISSUE success PR` / `finish-pr.sh ISSUE failed REASON_FILE` — closes / marks failed.
## Flow per loop iteration
1. `next-pr.sh` → issue#, branch, canonical id.
2. `start-pr.sh` → mark in-progress, create branch.
3. Loop driver dispatches a Claude Agent to implement the PR on the branch.
4. Loop runs `dotnet build` + `dotnet test`.
5. On green: `open-pr.sh`, `merge-pr.sh`, `finish-pr.sh success`.
6. On red: capture log → `finish-pr.sh failed log.txt`. Issue stays open with `queue/failed` label for retry.
## Environment
- Gitea repo: `dohertj2/lmxopcua` on `gitea.dohertylan.com`.
- Token: read from `%LOCALAPPDATA%\tea\config.yml` (or `$GITEA_TOKEN` override).
- Integration branch: `auto/driver-gaps` (created off master).
- Per-PR branches: `auto/<driver>/<plan-pr-id>`.
## Reset / debug
- Re-list eligible issues: `bash scripts/queue/next-pr.sh`.
- Manually unblock: remove `queue/blocked` label and add `queue/queued`.
- Drop a failed PR back into queue: remove `queue/failed`, add `queue/queued`.

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Reads scripts/queue/pr-manifest.yaml and creates one Gitea issue per PR.
# Idempotent: skips PRs whose canonical id already exists as an open issue.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib.sh"
if [ ! -f "$MANIFEST" ]; then
echo "manifest not found: $MANIFEST" >&2
exit 1
fi
# Collect existing canonical-id → issue# mapping (from queue-meta blocks)
EXISTING_JSON=$(api_repo GET "issues?state=all&type=issues&limit=200&page=1")
# multiple pages — keep paging until empty
PAGE=2
while :; do
PG=$(api_repo GET "issues?state=all&type=issues&limit=200&page=$PAGE")
COUNT=$(echo "$PG" | python -c "import sys,json; print(len(json.load(sys.stdin)))")
if [ "$COUNT" = "0" ]; then break; fi
EXISTING_JSON=$(python -c "import sys,json; a=json.loads(sys.argv[1]); b=json.loads(sys.argv[2]); print(json.dumps(a+b))" "$EXISTING_JSON" "$PG")
PAGE=$((PAGE+1))
done
python - "$MANIFEST" "$LABEL_MAP" <<'PY'
import json, sys, re, yaml, urllib.request, os
manifest_path, label_map_path = sys.argv[1], sys.argv[2]
gitea_token = os.environ["GITEA_TOKEN"]
api_base = "https://gitea.dohertylan.com/api/v1/repos/dohertj2/lmxopcua"
with open(manifest_path) as f: manifest = yaml.safe_load(f)
with open(label_map_path) as f: lmap = json.load(f)
def api(method, path, data=None):
req = urllib.request.Request(
f"{api_base}/{path}",
method=method,
headers={
"Authorization": f"token {gitea_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
data=json.dumps(data).encode() if data else None,
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
# Collect existing issues' canonical ids → issue#
existing = {}
page = 1
while True:
items = api("GET", f"issues?state=all&type=issues&limit=50&page={page}")
if not items: break
for it in items:
m = re.search(r'<!-- queue-meta\s*(\{.*?\})\s*-->', it.get("body","") or "", re.S)
if m:
try:
meta = json.loads(m.group(1))
if "id" in meta:
existing[meta["id"]] = it["number"]
except: pass
page += 1
print(f"existing queue issues: {len(existing)}")
filed = 0
skipped = 0
for pr in manifest["prs"]:
if pr["id"] in existing:
skipped += 1
continue
title = f"[{pr['driver']}] {pr['title']}"
meta = {
"id": pr["id"],
"driver": pr["driver"],
"phase": pr["phase"],
"plan_pr_id": pr.get("plan_pr_id",""),
"deps": pr.get("deps", []),
"cross_driver": pr.get("cross_driver", False),
}
body_parts = [
f"<!-- queue-meta\n{json.dumps(meta)}\n-->",
"## Auto-managed PR — Mode B (autonomous)",
f"**Driver**: `{pr['driver']}` **Phase**: `{pr['phase']}` **Plan PR**: `{pr.get('plan_pr_id','')}`",
f"**Plan**: [`{pr.get('plan_anchor','docs/plans/' + pr['driver'] + '-plan.md')}`]({pr.get('plan_anchor','../docs/plans/' + pr['driver'] + '-plan.md')})",
f"**Effort**: `{pr.get('effort','M')}` **Cross-driver**: `{pr.get('cross_driver', False)}`",
"",
"## Summary",
pr.get("summary","_(see plan)_"),
]
if pr.get("files"):
body_parts += ["", "## Source files", *[f"- `{f}`" for f in pr["files"]]]
if pr.get("docs"):
body_parts += ["", "## Docs", *[f"- `{d}`" for d in pr["docs"]]]
if pr.get("fixture"):
body_parts += ["", "## Fixture", *[f"- `{x}`" for x in pr["fixture"]]]
if pr.get("e2e"):
body_parts += ["", "## E2E", *[f"- `{x}`" for x in pr["e2e"]]]
if pr.get("deps"):
body_parts += ["", "## Depends on", *[f"- canonical: `{d}`" for d in pr["deps"]]]
if pr.get("notes"):
body_parts += ["", "## Notes", pr["notes"]]
body_parts += ["",
"---",
f"_Branch: `auto/{pr['driver']}/{pr.get('plan_pr_id','').replace('/','-')}`. Target: `auto/driver-gaps`._"]
body = "\n".join(body_parts)
label_names = [
f"driver/{pr['driver']}",
f"phase/{pr['phase']}",
"queue/queued",
"auto-managed",
]
if pr.get("cross_driver"): label_names.append("cross-driver")
label_ids = [lmap[n] for n in label_names if n in lmap]
issue = api("POST", "issues", {"title": title, "body": body, "labels": label_ids})
print(f" filed #{issue['number']}: {pr['id']}")
filed += 1
print(f"\nfiled {filed}, skipped (existing) {skipped}")
PY

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Closes the issue (success) or marks failed and reopens for retry.
# Usage:
# finish-pr.sh ISSUE_NUM success PR_NUM
# finish-pr.sh ISSUE_NUM failed REASON_FILE
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib.sh"
ISSUE="${1:?ISSUE_NUM required}"
RESULT="${2:?success|failed required}"
ARG3="${3:?PR_NUM or REASON_FILE required}"
INPROG=$(python -c "import json; print(json.load(open('$LABEL_MAP'))['queue/in-progress'])")
DONE=$(python -c "import json; print(json.load(open('$LABEL_MAP'))['queue/done'])")
FAILED=$(python -c "import json; print(json.load(open('$LABEL_MAP'))['queue/failed'])")
api_repo DELETE "issues/$ISSUE/labels/$INPROG" >/dev/null || true
case "$RESULT" in
success)
PR_NUM="$ARG3"
api_repo POST "issues/$ISSUE/labels" "{\"labels\":[$DONE]}" >/dev/null
BODY=$(python -c "import json; print(json.dumps({'body':'✅ Auto-loop completed. Merged via PR #$PR_NUM.'}))")
api_repo POST "issues/$ISSUE/comments" "$BODY" >/dev/null
api_repo PATCH "issues/$ISSUE" '{"state":"closed"}' >/dev/null
echo " issue #$ISSUE closed (PR #$PR_NUM merged)"
;;
failed)
REASON_FILE="$ARG3"
REASON=$(cat "$REASON_FILE" 2>/dev/null | head -c 4000 || echo "(no reason file)")
api_repo POST "issues/$ISSUE/labels" "{\"labels\":[$FAILED]}" >/dev/null
BODY=$(python -c "import json,sys; r=open('$REASON_FILE').read()[:4000] if __import__('os').path.exists('$REASON_FILE') else '(no log)'; print(json.dumps({'body':'❌ Auto-loop failed.\n\n\`\`\`\n'+r+'\n\`\`\`'}))")
api_repo POST "issues/$ISSUE/comments" "$BODY" >/dev/null
echo " issue #$ISSUE marked failed (still open for retry)"
;;
*)
echo "unknown result: $RESULT" >&2; exit 1 ;;
esac

57
scripts/queue/lib.sh Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Shared helpers for the Gitea-backed plan-execution queue.
set -euo pipefail
GITEA_URL="https://gitea.dohertylan.com"
GITEA_REPO="dohertj2/lmxopcua"
GITEA_API="$GITEA_URL/api/v1"
if [ -z "${GITEA_TOKEN:-}" ]; then
TEA_CONFIG="${LOCALAPPDATA:-$HOME/AppData/Local}/tea/config.yml"
if [ ! -f "$TEA_CONFIG" ]; then
TEA_CONFIG="$HOME/.config/tea/config.yml"
fi
GITEA_TOKEN="$(awk '/token:/{gsub(/[ \t]/,"",$2); print $2; exit}' "$TEA_CONFIG" 2>/dev/null || true)"
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "lib.sh: GITEA_TOKEN not set and tea config not readable" >&2
exit 1
fi
export GITEA_TOKEN
INTEGRATION_BRANCH="auto/driver-gaps"
QUEUE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && { pwd -W 2>/dev/null || pwd; })"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && { pwd -W 2>/dev/null || pwd; })"
MANIFEST="$QUEUE_ROOT/pr-manifest.yaml"
LABEL_MAP="$QUEUE_ROOT/.label-ids.json"
LABEL_QUEUED="queue/queued"
LABEL_IN_PROGRESS="queue/in-progress"
LABEL_BLOCKED="queue/blocked"
LABEL_FAILED="queue/failed"
LABEL_DONE="queue/done"
LABEL_AUTO="auto-managed"
LABEL_CROSS="cross-driver"
api() {
local method="$1" path="$2" data="${3:-}"
if [ -n "$data" ]; then
curl -sf -X "$method" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$GITEA_API/$path"
else
curl -sf -X "$method" \
-H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/$path"
fi
}
api_repo() {
api "$1" "repos/$GITEA_REPO/$2" "${3:-}"
}
label_id() {
python -c "import json,sys; m=json.load(open('$LABEL_MAP')); print(m['$1'])"
}

View File

@@ -0,0 +1,57 @@
# Loop iteration prompt (Mode B autonomous)
This is the single self-contained prompt that `/loop` re-fires until the queue empties. Each iteration handles exactly one PR end-to-end.
---
You are running one iteration of the autonomous plan-execution loop. The queue lives in Gitea at `dohertj2/lmxopcua`. Helpers: `scripts/queue/*.sh`.
## Step 1 — pick the next PR
Run `bash scripts/queue/next-pr.sh`. It returns JSON.
- If `{"empty": true}` → the queue is drained. **Do not call ScheduleWakeup.** Report "queue empty — loop terminating" and exit. The /loop will end.
- Otherwise parse: `issue_num`, `canonical_id`, `driver`, `phase`, `plan_pr_id`, `branch`, `title`, `url`.
## Step 2 — claim it
Run `bash scripts/queue/start-pr.sh "$ISSUE_NUM" "$BRANCH"`. This swaps `queue/queued``queue/in-progress` and creates the branch off `auto/driver-gaps`.
## Step 3 — pull the issue body
Run `curl -sf -H "Authorization: token $(awk '/token:/{print $2}' "$LOCALAPPDATA/tea/config.yml")" "https://gitea.dohertylan.com/api/v1/repos/dohertj2/lmxopcua/issues/$ISSUE_NUM"` and extract the `body` field. The body contains the Plan link, summary, source files, docs/fixture/e2e files.
## Step 4 — implement on a worktree
Dispatch a general-purpose Agent with `isolation: "worktree"`. Brief it with:
- the issue body verbatim
- the linked plan section (read `docs/plans/<driver>-plan.md` and quote the relevant per-PR detail)
- explicit instructions: implement the source-file changes, the doc updates, the fixture extensions, and the e2e test additions named in the issue
- run `dotnet build c:/Users/dohertj2/Desktop/lmxopcua/ZB.MOM.WW.OtOpcUa.slnx` until green
- run `dotnet test` for the relevant test project until green
- commit on `$BRANCH` with message `Auto: <canonical_id> — <short summary>` followed by `Closes #$ISSUE_NUM`
- return a brief summary of what changed
## Step 5 — verify and push
Verify the agent did commit + push. If branch isn't pushed, push it: `git push origin "$BRANCH"`.
## Step 6 — open PR
Build a body file: include the issue summary + the agent's summary. Then:
```
PR_NUM=$(bash scripts/queue/open-pr.sh "$ISSUE_NUM" "$BRANCH" "$TITLE" /tmp/pr-body.md)
```
## Step 7 — auto-merge (Mode B)
Run `bash scripts/queue/merge-pr.sh "$PR_NUM"`.
## Step 8 — close issue
Run `bash scripts/queue/finish-pr.sh "$ISSUE_NUM" success "$PR_NUM"`.
## On failure
If anywhere from Step 4 onward fails (build red, tests red, agent gives up, push fails, merge conflict):
- write the failure log to `/tmp/loop-fail-$ISSUE_NUM.log`
- run `bash scripts/queue/finish-pr.sh "$ISSUE_NUM" failed /tmp/loop-fail-$ISSUE_NUM.log`
- the issue keeps `queue/failed` and stays open for retry
- **do not** retry the same issue this iteration; let the loop pick a different one next fire
## Re-arm
At the very end of the iteration (success OR failure), call `ScheduleWakeup` with the same `/loop` prompt and `delaySeconds: 60` to fire the next iteration.
If the queue was empty in Step 1, do NOT call ScheduleWakeup.
Report a one-line summary to the user before re-arming.

11
scripts/queue/merge-pr.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Merges a PR (Mode B autonomous merge into auto/driver-gaps).
# Usage: merge-pr.sh PR_NUM
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib.sh"
PR="${1:?PR_NUM required}"
PAYLOAD='{"Do":"merge","delete_branch_after_merge":true}'
api_repo POST "pulls/$PR/merge" "$PAYLOAD" >/dev/null
echo " PR #$PR merged into $INTEGRATION_BRANCH (branch deleted)"

77
scripts/queue/next-pr.sh Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Prints the next eligible queue issue as JSON: {issue_num, canonical_id, driver, plan_pr_id, branch, ...}
# Eligible = open + label queue/queued + all canonical deps closed.
# Picks lowest phase first, then lowest issue number within phase.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib.sh"
python - <<PY
import json, urllib.request, re, os, sys
token = os.environ["GITEA_TOKEN"]
api_base = "https://gitea.dohertylan.com/api/v1/repos/dohertj2/lmxopcua"
def api(path):
req = urllib.request.Request(f"{api_base}/{path}",
headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
# Gather all queue issues
issues = []
page = 1
while True:
items = api(f"issues?state=all&type=issues&limit=50&page={page}&labels=auto-managed")
if not items: break
issues.extend(items)
page += 1
by_id = {}
for it in issues:
m = re.search(r'<!-- queue-meta\s*(\{.*?\})\s*-->', it.get("body","") or "", re.S)
if not m: continue
try: meta = json.loads(m.group(1))
except: continue
by_id[meta["id"]] = (it, meta)
def is_done(issue):
if issue["state"] == "closed": return True
labels = {l["name"] for l in issue["labels"]}
return "queue/done" in labels
eligible = []
for cid, (it, meta) in by_id.items():
labels = {l["name"] for l in it["labels"]}
if it["state"] != "open": continue
if "queue/queued" not in labels: continue
deps = meta.get("deps", [])
blocked = False
for d in deps:
if d not in by_id:
blocked = True; break
if not is_done(by_id[d][0]):
blocked = True; break
if blocked: continue
eligible.append((meta.get("phase",99), it["number"], cid, it, meta))
if not eligible:
print(json.dumps({"empty": True}))
sys.exit(0)
eligible.sort(key=lambda x: (x[0], x[1]))
phase, num, cid, it, meta = eligible[0]
plan_pr = meta.get("plan_pr_id","").replace("/","-")
result = {
"empty": False,
"issue_num": num,
"canonical_id": cid,
"driver": meta["driver"],
"phase": meta["phase"],
"plan_pr_id": meta.get("plan_pr_id",""),
"title": it["title"],
"branch": f"auto/{meta['driver']}/{plan_pr}",
"url": it["html_url"],
}
print(json.dumps(result, indent=2))
PY

24
scripts/queue/open-pr.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Opens a PR from BRANCH into auto/driver-gaps, references the issue, sets ready/draft.
# Usage: open-pr.sh ISSUE_NUM BRANCH_NAME TITLE BODY_FILE
# Echoes the PR number on stdout.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib.sh"
ISSUE="${1:?}"; BRANCH="${2:?}"; TITLE="${3:?}"; BODY_FILE="${4:?}"
BODY=$(cat "$BODY_FILE")
PAYLOAD=$(python -c "
import json, sys
print(json.dumps({
'title': sys.argv[1],
'body': sys.argv[2] + '\n\nCloses #' + sys.argv[3],
'head': sys.argv[4],
'base': sys.argv[5],
}))
" "$TITLE" "$BODY" "$ISSUE" "$BRANCH" "$INTEGRATION_BRANCH")
PR=$(api_repo POST pulls "$PAYLOAD")
PR_NUM=$(echo "$PR" | python -c "import sys,json; print(json.load(sys.stdin)['number'])")
echo "$PR_NUM"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Idempotent: creates queue labels in Gitea and stores name→id map at .label-ids.json
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib.sh"
declare -A LABELS=(
["driver/abcip"]="0e8a16"
["driver/ablegacy"]="0e8a16"
["driver/focas"]="0e8a16"
["driver/opcuaclient"]="0e8a16"
["driver/s7"]="0e8a16"
["driver/twincat"]="0e8a16"
["phase/1"]="bfd4f2"
["phase/2"]="bfd4f2"
["phase/3"]="bfd4f2"
["phase/4"]="bfd4f2"
["phase/5"]="bfd4f2"
["phase/6"]="bfd4f2"
["queue/queued"]="d4c5f9"
["queue/in-progress"]="fbca04"
["queue/blocked"]="b60205"
["queue/failed"]="b60205"
["queue/done"]="2ea44f"
["auto-managed"]="cccccc"
["cross-driver"]="d93f0b"
)
# Pull existing labels
EXISTING=$(api_repo GET "labels?limit=200")
emit_map() {
python - <<PY
import json, sys
existing = json.loads('''$EXISTING''')
print(json.dumps({l['name']: l['id'] for l in existing}, indent=2))
PY
}
# Create any missing
for name in "${!LABELS[@]}"; do
color="${LABELS[$name]}"
exists=$(echo "$EXISTING" | python -c "import json,sys; ls=json.load(sys.stdin); print('yes' if any(l['name']=='$name' for l in ls) else 'no')")
if [ "$exists" = "no" ]; then
payload=$(python -c "import json; print(json.dumps({'name':'$name','color':'#$color','description':'queue management'}))")
api_repo POST labels "$payload" >/dev/null
echo "created label: $name"
fi
done
# Refresh and write the map file
api_repo GET "labels?limit=200" | python -c "
import json, sys
ls = json.load(sys.stdin)
m = {l['name']: l['id'] for l in ls}
open('$LABEL_MAP','w').write(json.dumps(m, indent=2))
print(f'wrote {len(m)} labels to $LABEL_MAP')
"

31
scripts/queue/start-pr.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Marks an issue in-progress and creates its branch off the integration branch.
# Usage: start-pr.sh ISSUE_NUM BRANCH_NAME
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HERE/lib.sh"
ISSUE="${1:?ISSUE_NUM required}"
BRANCH="${2:?BRANCH_NAME required}"
# Swap labels: queued -> in-progress
QUEUED=$(python -c "import json; print(json.load(open('$LABEL_MAP'))['queue/queued'])")
INPROG=$(python -c "import json; print(json.load(open('$LABEL_MAP'))['queue/in-progress'])")
api_repo DELETE "issues/$ISSUE/labels/$QUEUED" >/dev/null || true
api_repo POST "issues/$ISSUE/labels" "{\"labels\":[$INPROG]}" >/dev/null
# Create branch off integration
EXISTS=$(api_repo GET "branches/$BRANCH" 2>/dev/null || echo "")
if [ -z "$EXISTS" ]; then
PAYLOAD=$(python -c "import json; print(json.dumps({'new_branch_name':'$BRANCH','old_branch_name':'$INTEGRATION_BRANCH'}))")
api_repo POST branches "$PAYLOAD" >/dev/null
echo " branch created: $BRANCH"
else
echo " branch exists: $BRANCH"
fi
# Comment
COMMENT=$(python -c "import json; print(json.dumps({'body':'🤖 Auto-loop picked this up. Branch: \`$BRANCH\`. Status: in-progress.'}))")
api_repo POST "issues/$ISSUE/comments" "$COMMENT" >/dev/null
echo " issue #$ISSUE marked in-progress"

View File

@@ -1,6 +1,6 @@
{ {
"ConnectionStrings": { "ConnectionStrings": {
"ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;" "ConfigDb": "Server=10.100.0.35,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
}, },
"Authentication": { "Authentication": {
"Ldap": { "Ldap": {

View File

@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// Reachability probe for the <c>ab_server</c> Docker container (libplctag's CIP /// Reachability probe for the <c>ab_server</c> Docker container (libplctag's CIP
/// simulator built via <c>Docker/Dockerfile</c>) or any real AB PLC the /// simulator built via <c>Docker/Dockerfile</c>) or any real AB PLC the
/// <c>AB_SERVER_ENDPOINT</c> env var points at. Parses /// <c>AB_SERVER_ENDPOINT</c> env var points at. Parses
/// <c>AB_SERVER_ENDPOINT</c> (default <c>localhost:44818</c>) + TCP-connects /// <c>AB_SERVER_ENDPOINT</c> (default <c>10.100.0.35:44818</c>) + TCP-connects
/// once at fixture construction. Tests skip via <see cref="AbServerFactAttribute"/> /// once at fixture construction. Tests skip via <see cref="AbServerFactAttribute"/>
/// / <see cref="AbServerTheoryAttribute"/> when the port isn't live, so /// / <see cref="AbServerTheoryAttribute"/> when the port isn't live, so
/// <c>dotnet test</c> stays green on a fresh clone without Docker running. /// <c>dotnet test</c> stays green on a fresh clone without Docker running.
@@ -28,7 +28,7 @@ public sealed class AbServerFixture : IAsyncLifetime
/// instantiate the fixture with the profile matching their compose-file service.</summary> /// instantiate the fixture with the profile matching their compose-file service.</summary>
public AbServerProfile Profile { get; } public AbServerProfile Profile { get; }
public string Host { get; } = "127.0.0.1"; public string Host { get; } = "10.100.0.35";
public int Port { get; } = AbServerProfile.DefaultPort; public int Port { get; } = AbServerProfile.DefaultPort;
public AbServerFixture() : this(KnownProfiles.ControlLogix) { } public AbServerFixture() : this(KnownProfiles.ControlLogix) { }
@@ -59,7 +59,7 @@ public sealed class AbServerFixture : IAsyncLifetime
TcpProbe(ResolveHost(), ResolvePort()); TcpProbe(ResolveHost(), ResolvePort());
private static string ResolveHost() => private static string ResolveHost() =>
Environment.GetEnvironmentVariable(EndpointEnvVar)?.Split(':', 2)[0] ?? "127.0.0.1"; Environment.GetEnvironmentVariable(EndpointEnvVar)?.Split(':', 2)[0] ?? "10.100.0.35";
private static int ResolvePort() private static int ResolvePort()
{ {
@@ -84,7 +84,7 @@ public sealed class AbServerFixture : IAsyncLifetime
/// <summary> /// <summary>
/// <c>[Fact]</c>-equivalent that skips when ab_server isn't reachable — accepts a /// <c>[Fact]</c>-equivalent that skips when ab_server isn't reachable — accepts a
/// live Docker listener on <c>localhost:44818</c> or an <c>AB_SERVER_ENDPOINT</c> /// live Docker listener on <c>10.100.0.35:44818</c> or an <c>AB_SERVER_ENDPOINT</c>
/// override pointing at a real PLC. /// override pointing at a real PLC.
/// </summary> /// </summary>
public sealed class AbServerFactAttribute : FactAttribute public sealed class AbServerFactAttribute : FactAttribute

View File

@@ -17,6 +17,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: otopcua-ab-server:libplctag-release image: otopcua-ab-server:libplctag-release
container_name: otopcua-ab-server-controllogix container_name: otopcua-ab-server-controllogix
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "44818:44818" - "44818:44818"
@@ -40,6 +42,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: otopcua-ab-server-compactlogix container_name: otopcua-ab-server-compactlogix
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "44818:44818" - "44818:44818"
@@ -63,6 +67,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: otopcua-ab-server-micro800 container_name: otopcua-ab-server-micro800
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "44818:44818" - "44818:44818"
@@ -82,6 +88,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: otopcua-ab-server-guardlogix container_name: otopcua-ab-server-guardlogix
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "44818:44818" - "44818:44818"

View File

@@ -17,6 +17,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: otopcua-pymodbus:3.13.0 image: otopcua-pymodbus:3.13.0
container_name: otopcua-pymodbus-standard container_name: otopcua-pymodbus-standard
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "5020:5020" - "5020:5020"
@@ -34,6 +36,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: otopcua-pymodbus-dl205 container_name: otopcua-pymodbus-dl205
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "5020:5020" - "5020:5020"
@@ -51,6 +55,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: otopcua-pymodbus-mitsubishi container_name: otopcua-pymodbus-mitsubishi
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "5020:5020" - "5020:5020"
@@ -68,6 +74,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: otopcua-pymodbus-s7_1500 container_name: otopcua-pymodbus-s7_1500
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "5020:5020" - "5020:5020"
@@ -91,6 +99,8 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: otopcua-modbus-exception-injector container_name: otopcua-modbus-exception-injector
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "5020:5020" - "5020:5020"

View File

@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary> /// <summary>
/// Reachability probe for a Modbus TCP simulator (pymodbus in Docker, see /// Reachability probe for a Modbus TCP simulator (pymodbus in Docker, see
/// <c>Docker/docker-compose.yml</c>) or a real PLC. Parses /// <c>Docker/docker-compose.yml</c>) or a real PLC. Parses
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at /// <c>MODBUS_SIM_ENDPOINT</c> (default <c>10.100.0.35:5020</c>) and TCP-connects once at
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls /// fixture construction. Each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running /// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
/// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in /// simulator still passes `dotnet test` cleanly — matches the Galaxy live-smoke pattern in
@@ -30,7 +30,7 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the // Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
// port baked into the pymodbus simulator JSON profiles in Docker/profiles/. Override with // port baked into the pymodbus simulator JSON profiles in Docker/profiles/. Override with
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502. // MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
private const string DefaultEndpoint = "localhost:5020"; private const string DefaultEndpoint = "10.100.0.35:5020";
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT"; private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
public string Host { get; } public string Host { get; }

View File

@@ -8,6 +8,8 @@ services:
opc-plc: opc-plc:
image: mcr.microsoft.com/iotedge/opc-plc:2.14.10 image: mcr.microsoft.com/iotedge/opc-plc:2.14.10
container_name: otopcua-opc-plc container_name: otopcua-opc-plc
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "50000:50000" - "50000:50000"

View File

@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// Reachability probe for an <c>opc-plc</c> simulator (Microsoft Industrial IoT's /// Reachability probe for an <c>opc-plc</c> simulator (Microsoft Industrial IoT's
/// OPC UA PLC from <c>mcr.microsoft.com/iotedge/opc-plc</c>) or any real OPC UA /// OPC UA PLC from <c>mcr.microsoft.com/iotedge/opc-plc</c>) or any real OPC UA
/// server the <c>OPCUA_SIM_ENDPOINT</c> env var points at. Parses /// server the <c>OPCUA_SIM_ENDPOINT</c> env var points at. Parses
/// <c>OPCUA_SIM_ENDPOINT</c> (default <c>opc.tcp://localhost:50000</c>), /// <c>OPCUA_SIM_ENDPOINT</c> (default <c>opc.tcp://10.100.0.35:50000</c>),
/// TCP-connects to the resolved host:port at collection init, and records a /// TCP-connects to the resolved host:port at collection init, and records a
/// <see cref="SkipReason"/> on failure. Tests call <c>Assert.Skip</c> on that, so /// <see cref="SkipReason"/> on failure. Tests call <c>Assert.Skip</c> on that, so
/// `dotnet test` stays green when Docker isn't running the simulator — mirrors the /// `dotnet test` stays green when Docker isn't running the simulator — mirrors the
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// </remarks> /// </remarks>
public sealed class OpcPlcFixture : IAsyncDisposable public sealed class OpcPlcFixture : IAsyncDisposable
{ {
private const string DefaultEndpoint = "opc.tcp://localhost:50000"; private const string DefaultEndpoint = "opc.tcp://10.100.0.35:50000";
private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT"; private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT";
/// <summary>Full <c>opc.tcp://host:port</c> URL the driver session should connect to.</summary> /// <summary>Full <c>opc.tcp://host:port</c> URL the driver session should connect to.</summary>

View File

@@ -14,6 +14,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: otopcua-python-snap7:1.0 image: otopcua-python-snap7:1.0
container_name: otopcua-python-snap7-s7_1500 container_name: otopcua-python-snap7-s7_1500
labels:
project: lmxopcua
restart: "no" restart: "no"
ports: ports:
- "1102:1102" - "1102:1102"

View File

@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
/// <summary> /// <summary>
/// Reachability probe for the python-snap7 simulator Docker container (see /// Reachability probe for the python-snap7 simulator Docker container (see
/// <c>Docker/docker-compose.yml</c>) or a real S7 PLC. Parses <c>S7_SIM_ENDPOINT</c> /// <c>Docker/docker-compose.yml</c>) or a real S7 PLC. Parses <c>S7_SIM_ENDPOINT</c>
/// (default <c>localhost:1102</c>) + TCP-connects once at fixture construction. /// (default <c>10.100.0.35:1102</c>) + TCP-connects once at fixture construction.
/// Tests check <see cref="SkipReason"/> + call <c>Assert.Skip</c> when unreachable, so /// Tests check <see cref="SkipReason"/> + call <c>Assert.Skip</c> when unreachable, so
/// `dotnet test` stays green on a fresh box without the simulator installed — /// `dotnet test` stays green on a fresh box without the simulator installed —
/// mirrors the <c>ModbusSimulatorFixture</c> pattern. /// mirrors the <c>ModbusSimulatorFixture</c> pattern.
@@ -35,7 +35,7 @@ public sealed class Snap7ServerFixture : IAsyncDisposable
{ {
// Default 1102 (non-privileged) matches Docker/server.py. Override with // Default 1102 (non-privileged) matches Docker/server.py. Override with
// S7_SIM_ENDPOINT to point at a real PLC on its native 102. // S7_SIM_ENDPOINT to point at a real PLC on its native 102.
private const string DefaultEndpoint = "localhost:1102"; private const string DefaultEndpoint = "10.100.0.35:1102";
private const string EndpointEnvVar = "S7_SIM_ENDPOINT"; private const string EndpointEnvVar = "S7_SIM_ENDPOINT";
public string Host { get; } public string Host { get; }