From 2d07d716dcb8b40a2e19fd6b28ff0890d5d2ec6b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 30 Apr 2026 08:28:01 -0400 Subject: [PATCH] Recover stashed driver-gaps work from pre-v2-mxgw-merge working tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/featuregaps.md | 623 +++++++ docs/plans/abcip-plan.md | 682 ++++++++ docs/plans/ablegacy-plan.md | 470 ++++++ docs/plans/focas-plan.md | 807 +++++++++ docs/plans/opcuaclient-plan.md | 863 ++++++++++ docs/plans/s7-plan.md | 807 +++++++++ docs/plans/twincat-plan.md | 899 ++++++++++ docs/v2/focas-deployment.md | 159 ++ .../v2/implementation/focas-simulator-plan.md | 315 ++++ followup.md | 87 + scripts/e2e/test-opcuaclient.ps1 | 6 +- scripts/queue/.label-ids.json | 21 + scripts/queue/.partial-twincat.yaml | 320 ++++ scripts/queue/README.md | 36 + scripts/queue/file-issues.sh | 122 ++ scripts/queue/finish-pr.sh | 39 + scripts/queue/lib.sh | 57 + scripts/queue/loop-iteration.md | 57 + scripts/queue/merge-pr.sh | 11 + scripts/queue/next-pr.sh | 77 + scripts/queue/open-pr.sh | 24 + scripts/queue/pr-manifest.yaml | 1473 +++++++++++++++++ scripts/queue/setup-labels.sh | 58 + scripts/queue/start-pr.sh | 31 + src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json | 2 +- .../AbServerFixture.cs | 8 +- .../Docker/docker-compose.yml | 8 + .../Docker/docker-compose.yml | 10 + .../ModbusSimulatorFixture.cs | 4 +- .../Docker/docker-compose.yml | 2 + .../OpcPlcFixture.cs | 4 +- .../Docker/docker-compose.yml | 2 + .../Snap7ServerFixture.cs | 4 +- 33 files changed, 8074 insertions(+), 14 deletions(-) create mode 100644 docs/featuregaps.md create mode 100644 docs/plans/abcip-plan.md create mode 100644 docs/plans/ablegacy-plan.md create mode 100644 docs/plans/focas-plan.md create mode 100644 docs/plans/opcuaclient-plan.md create mode 100644 docs/plans/s7-plan.md create mode 100644 docs/plans/twincat-plan.md create mode 100644 docs/v2/focas-deployment.md create mode 100644 docs/v2/implementation/focas-simulator-plan.md create mode 100644 followup.md create mode 100644 scripts/queue/.label-ids.json create mode 100644 scripts/queue/.partial-twincat.yaml create mode 100644 scripts/queue/README.md create mode 100644 scripts/queue/file-issues.sh create mode 100644 scripts/queue/finish-pr.sh create mode 100644 scripts/queue/lib.sh create mode 100644 scripts/queue/loop-iteration.md create mode 100644 scripts/queue/merge-pr.sh create mode 100644 scripts/queue/next-pr.sh create mode 100644 scripts/queue/open-pr.sh create mode 100644 scripts/queue/pr-manifest.yaml create mode 100644 scripts/queue/setup-labels.sh create mode 100644 scripts/queue/start-pr.sh diff --git a/docs/featuregaps.md b/docs/featuregaps.md new file mode 100644 index 0000000..2479b47 --- /dev/null +++ b/docs/featuregaps.md @@ -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//` 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 `:`. +- **[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 ~5–10 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)` 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 ` 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) diff --git a/docs/plans/abcip-plan.md b/docs/plans/abcip-plan.md new file mode 100644 index 0000000..67e374c --- /dev/null +++ b/docs/plans/abcip-plan.md @@ -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//_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)? diff --git a/docs/plans/ablegacy-plan.md b/docs/plans/ablegacy-plan.md new file mode 100644 index 0000000..1f1e6ba --- /dev/null +++ b/docs/plans/ablegacy-plan.md @@ -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 `[]` 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//_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 1–5 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,,2,` — 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,,2,` 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,,2,` 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,,2,` 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. diff --git a/docs/plans/focas-plan.md b/docs/plans/focas-plan.md new file mode 100644 index 0000000..14f0c5d --- /dev/null +++ b/docs/plans/focas-plan.md @@ -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 1–3 are mutually independent and can ship in any order. Phase 4 +deliberately follows Phase 2 so writes ride on top of the multi-path +addressing already in place. Phase 5 tags onto the cycle-time node from +Phase 1. + +## Per-PR detail + +### Phase 1 — fixed-tree expansion + +Common shape: each PR adds one or more wire calls in +`Wire/FocasWireClient.cs`, surfaces them on `IFocasClient`, plumbs them +into `FocasDriver`'s `FixedTreeLoopAsync` cadences (axis 250 ms / program +1 s / timer 30 s) and the `TryReadFixedTree` synthesizer, then adds +fakes + assertions. + +**PR F1-a — ODBST status flags as fixed-tree nodes (#12)** +- Scope: project the 9 fields of `cnc_rdcncstat` (`tmmode`, `aut`, `run`, + `motion`, `mstb`, `emergency`, `alarm`, `edit`, `dummy`) 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 2–10 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 14–18** — revoke the unconditional + "OtOpcUa is read-only against FOCAS… Writes return BadNotWritable + by design" callout. Replace with a "Writes (opt-in, off by + default)" subsection that 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 42–43** — 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 100–116** — 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 + 105–108) 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 14–18; **F2-b** adds the multi-path subsection | +| `docs/drivers/FOCAS-Test-Fixture.md` | F1-a, F1-d, F1-f, F2-a, F2-b, F3-a, F4-a | **F4-a** revokes the "`IWritable` intentionally returns `BadNotWritable`" callout at lines 42–43 | +| `docs/Driver.FOCAS.Cli.md` | F1-b, F1-c, F2-a, F2-b, F4-a, F4-b, F4-c, F4-d | **F4-a** qualifies the read-only stance at lines 100–116; **F4-d** adds `--cnc-password` flag | +| `docs/v2/focas-deployment.md` | F1-f, F3-a, F4-a, F4-b, F4-c, F4-d | **F4-b** adds "Write safety" section; **F4-d** adds "FOCAS password handling" section | +| `docs/v2/focas-version-matrix.md` | F1-c, F1-d, F2-a, F2-c, F2-d | **F1-d** adds capability-suppression rows for tooling/offsets | +| `docs/v2/implementation/focas-wire-protocol.md` | F1-a, F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-b, F4-c, F4-d | **F1-d** documents three new structs (ODBTOFS, ODBTLIFE5, IODBZOR); **F4-d** resolves the `cnc_wrunlockparam` open question | +| `docs/v2/implementation/focas-simulator-plan.md` | F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-a, F4-b, F4-c, F4-d | Each PR appends to the protocol surface table; F4-* close out Stream C status | +| `docs/v2/decisions.md` (new entry) | F4-a | Net-new decision-record for the read-only reversal | +| `docs/featuregaps.md` | F4-a | Updates Build = Yes annotation for #1 / #3 with "shipping behind flag" | + +### Fixture (focas-mock) extensions + +The vendored Python `focas-mock` simulator under +`tests/.../IntegrationTests/Docker/focas-mock/` gains the following +new command-id handlers and per-profile state: + +| PR | Mock extension | +| --- | --- | +| F1-a | `cnc_rdcncstat` full-struct response | +| F1-b | Seeded values for parameters 6711/6712/6713 in every profile JSON | +| F1-c | New `cnc_modal` handler + canned modal payload per profile | +| F1-d | `cnc_rdtofs` / `cnc_rdtlife*` / `cnc_rdzofs` handlers + per-profile tool/offset tables, plus a `tools_per_series` profile knob | +| F1-e | `cnc_rdopmsg3` / `cnc_rdactpt` handlers + `mock_patch_opmsg` admin endpoint | +| F1-f | `cnc_getfigure` handler + per-profile `decimal_places` field | +| F2-a | `cnc_rddiag` / `cnc_rddiagdgn` handlers + per-profile diagnostic numbers | +| F2-b | Per-path state isolation; new `path_count` profile field; new `thirtyone_i_dual_path` compose profile | +| F2-c | No mock change (16i profile already declares F/G ranges) | +| F2-d | Wire-call counter admin endpoint | +| F3-a | Ring-buffer alarm history + `mock_patch_alarmhistory` admin endpoint | +| F4-a | Stub branch returning `BadNotSupported` for write commands | +| F4-b | `cnc_wrparam` / `cnc_wrmacro` handlers (with `EW_PASSWD` when locked); `mock_get_last_write` admin endpoint | +| F4-c | `pmc_wrpmcrng` handler with byte-aligned write semantics | +| F4-d | `cnc_wrunlockparam` handler; `mock_set_password` admin endpoint; locked-state on the param-write path | +| F5-a | `SimulateCycleCompletionAsync` helper on `FocasSimFixture` (no new mock command) | + +`FocasSimFixture` (in +`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs`) +gains corresponding admin-API client helpers for each new endpoint. + +### Integration tests (per phase) + +| Phase | New / extended integration tests under `tests/.../FOCAS.IntegrationTests/Series/` | +| --- | --- | +| Phase 1 | `StatusFlagsPopulateTests.cs`, `ProductionPopulatesTests.cs`, `ModalPopulatesTests.cs`, `ToolingPopulatesTests.cs`, `OffsetsPopulatesTests.cs`, `OperatorMessagesPopulateTests.cs`, `DecimalScalingTests.cs`, `DiagnosticsCountersTests.cs` | +| Phase 2 | `DiagAddressTests.cs`, `MultiPathTests.cs`, `PmcCoalescingTests.cs` (plus a 16i row in `FocasCapabilityMatrixTests.cs` for F2-c) | +| Phase 3 | `AlarmHistoryProjectionTests.cs` | +| Phase 4 | `ParameterWriteTests.cs`, `MacroWriteTests.cs`, `PmcRangeWriteTests.cs`, `PmcBitRmwIntegrationTests.cs`, `PasswordUnlockTests.cs` plus ACL tests under `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.cs` and `FocasPmcWriteAclTests.cs` | +| Phase 5 | `CycleDeltaTests.cs` | + +### E2E script (`scripts/e2e/test-focas.ps1`) updates + +| PR | Change | +| --- | --- | +| F1-f | New `-CheckDecimalScaling` switch | +| F2-b | New `-Paths` switch (matrix mode iterates per declared path) | +| F2-c | Adds F0.0 / G50.5 probes to the 16i row of the per-series matrix | +| F4-a | Adds `-Write` switch (no-op stage in F4-a; populated by F4-b/c) | +| F4-b | Populates `-Write` stage with macro + parameter round-trip writes | +| F4-c | Extends `-Write` stage with PMC bit round-trip | +| F4-d | Adds `-CncPassword` parameter, threaded through to the CLI | + +`scripts/integration/run-focas.ps1` does not change shape across the +plan — it remains the compose up/test/compose down wrapper. New +profiles registered by F2-b are automatically picked up via the +existing `-Profile` switch. + +### Read-only callouts requiring revocation in Phase 4 + +For reviewer benefit, the explicit read-only callouts that **F4-a +must revoke or qualify** in the same PR that flips the design choice: + +- `docs/drivers/FOCAS.md` lines 14–18 ("OtOpcUa is **read-only** + against FOCAS… Writes return `BadNotWritable` by design.") +- `docs/drivers/FOCAS-Test-Fixture.md` lines 42–43 ("`IWritable` + intentionally returns `BadNotWritable` — OtOpcUa is read-only + against FOCAS.") +- `docs/Driver.FOCAS.Cli.md` lines 100–116 (write section is already + documented but predates the server-side flag; needs a + server-enforced-ACL note) +- `docs/featuregaps.md` (FOCAS row entries for #1 and #3 carry the + same read-only-by-design framing — flip annotation) + +## Skip-rated items (for context) + +These appear in the featuregaps recommendations table as Build = No; +recapped here so reviewers can confirm the scope decision rather than +re-deriving it from `featuregaps.md`: + +- **#2 HSSB transport** — PCI hardware, declining install base, + reopens the Fwlib distribution problem the wire client deliberately + closed. +- **#5 Series 15 / Power Mate D-H / Series 35i** — very legacy; small + install base. Capability matrix already accepts `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. diff --git a/docs/plans/opcuaclient-plan.md b/docs/plans/opcuaclient-plan.md new file mode 100644 index 0000000..4a1c103 --- /dev/null +++ b/docs/plans/opcuaclient-plan.md @@ -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`) — note + this requires coordinating with `ISubscribable` so the per-tag carrier + reaches the driver. + +**Files**: +- `src/.../Core.Abstractions/ISubscribable.cs` — add overload + `SubscribeAsync(IReadOnlyList, ...)` 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` — 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 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` (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. diff --git a/docs/plans/s7-plan.md b/docs/plans/s7-plan.md new file mode 100644 index 0000000..d023085 --- /dev/null +++ b/docs/plans/s7-plan.md @@ -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)` 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 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. diff --git a/docs/plans/twincat-plan.md b/docs/plans/twincat-plan.md new file mode 100644 index 0000000..43581a1 --- /dev/null +++ b/docs/plans/twincat-plan.md @@ -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 + _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(...)` 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 _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(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: (Sum commands) +- Beckhoff InfoSys: (NotificationSettings) +- Beckhoff GitHub: diff --git a/docs/v2/focas-deployment.md b/docs/v2/focas-deployment.md new file mode 100644 index 0000000..6a40ed1 --- /dev/null +++ b/docs/v2/focas-deployment.md @@ -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: + +``` +\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": "", + "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 = ` — `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. diff --git a/docs/v2/implementation/focas-simulator-plan.md b/docs/v2/implementation/focas-simulator-plan.md new file mode 100644 index 0000000..cdd4340 --- /dev/null +++ b/docs/v2/implementation/focas-simulator-plan.md @@ -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 diff --git a/followup.md b/followup.md new file mode 100644 index 0000000..878d814 --- /dev/null +++ b/followup.md @@ -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. diff --git a/scripts/e2e/test-opcuaclient.ps1 b/scripts/e2e/test-opcuaclient.ps1 index 5a73a8d..a138b4b 100644 --- a/scripts/e2e/test-opcuaclient.ps1 +++ b/scripts/e2e/test-opcuaclient.ps1 @@ -11,7 +11,7 @@ of this test use `otopcua-cli` against two different endpoints: 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 OpcUaClient driver instance (opc.tcp://localhost:4840) @@ -72,7 +72,7 @@ .PARAMETER RemoteUrl 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 Local OtOpcUa server endpoint. Default opc.tcp://localhost:4840. @@ -146,7 +146,7 @@ #> param( - [string]$RemoteUrl = "opc.tcp://localhost:50000", + [string]$RemoteUrl = "opc.tcp://10.100.0.35:50000", [string]$OpcUaUrl = "opc.tcp://localhost:4840", [string]$RemoteNodeId = "ns=3;s=FastUInt1", [Parameter(Mandatory)] [string]$BridgeNodeId, diff --git a/scripts/queue/.label-ids.json b/scripts/queue/.label-ids.json new file mode 100644 index 0000000..8cf5d32 --- /dev/null +++ b/scripts/queue/.label-ids.json @@ -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 +} \ No newline at end of file diff --git a/scripts/queue/.partial-twincat.yaml b/scripts/queue/.partial-twincat.yaml new file mode 100644 index 0000000..01da846 --- /dev/null +++ b/scripts/queue/.partial-twincat.yaml @@ -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." diff --git a/scripts/queue/README.md b/scripts/queue/README.md new file mode 100644 index 0000000..cc18153 --- /dev/null +++ b/scripts/queue/README.md @@ -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//`. + +## 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`. diff --git a/scripts/queue/file-issues.sh b/scripts/queue/file-issues.sh new file mode 100644 index 0000000..d333a54 --- /dev/null +++ b/scripts/queue/file-issues.sh @@ -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'', 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"", + "## 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 diff --git a/scripts/queue/finish-pr.sh b/scripts/queue/finish-pr.sh new file mode 100644 index 0000000..1759d8b --- /dev/null +++ b/scripts/queue/finish-pr.sh @@ -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 diff --git a/scripts/queue/lib.sh b/scripts/queue/lib.sh new file mode 100644 index 0000000..d5e8003 --- /dev/null +++ b/scripts/queue/lib.sh @@ -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'])" +} diff --git a/scripts/queue/loop-iteration.md b/scripts/queue/loop-iteration.md new file mode 100644 index 0000000..b7f0384 --- /dev/null +++ b/scripts/queue/loop-iteration.md @@ -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/-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: ` 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. diff --git a/scripts/queue/merge-pr.sh b/scripts/queue/merge-pr.sh new file mode 100644 index 0000000..3090db3 --- /dev/null +++ b/scripts/queue/merge-pr.sh @@ -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)" diff --git a/scripts/queue/next-pr.sh b/scripts/queue/next-pr.sh new file mode 100644 index 0000000..310a3a2 --- /dev/null +++ b/scripts/queue/next-pr.sh @@ -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 - <', 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 diff --git a/scripts/queue/open-pr.sh b/scripts/queue/open-pr.sh new file mode 100644 index 0000000..da5e92a --- /dev/null +++ b/scripts/queue/open-pr.sh @@ -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" diff --git a/scripts/queue/pr-manifest.yaml b/scripts/queue/pr-manifest.yaml new file mode 100644 index 0000000..666c167 --- /dev/null +++ b/scripts/queue/pr-manifest.yaml @@ -0,0 +1,1473 @@ +prs: +- id: abcip-1.1 + driver: abcip + phase: 1 + plan_pr_id: "1.1" + title: "AbCip — LINT/ULINT 64-bit fidelity" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Replace the truncating Int32 widening at AbCipDataType.cs:53 with Int64 routing across decode, encode, and the DriverDataType map. Includes DT (epoch-millis on Logix v32+ surfaces as LINT, not DINT). The runtime already calls GetInt64/SetInt64 correctly; the gap is the surface enum flattening into Int32. May require adding Int64/UInt64 members to Core.Abstractions/DriverDataType.cs. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs] + docs: [docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-abcip.ps1, scripts/smoke/seed-abcip-smoke.sql] + effort: S + deps: [] + cross_driver: false + notes: "Possible Core.Abstractions enum extension." +- id: abcip-1.2 + driver: abcip + phase: 1 + plan_pr_id: "1.2" + title: "AbCip — Native STRING / STRINGnn variant decoding" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Today AbCipDataType.String flattens any Logix STRING UDT into a .NET string via libplctag GetString(0). Logix programs commonly define STRING_20/40/80 variants. Add StringLength field to AbCipStructureMember and AbCipTagDefinition, thread into the libplctag string-cap hint. Investigate libplctag's str_max_capacity / str_count_word_bytes attributes; extend LibplctagTagRuntime if needed. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDataType.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-abcip.ps1, scripts/smoke/seed-abcip-smoke.sql] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: abcip-1.3 + driver: abcip + phase: 1 + plan_pr_id: "1.3" + title: "AbCip — Array-slice read addressing Tag[0..N]" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Add slice syntax Tag[0..15] to 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: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTagPath.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs] + docs: [docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: L + deps: [abcip-1.1] + cross_driver: false + notes: "" +- id: abcip-1.4 + driver: abcip + phase: 1 + plan_pr_id: "1.4" + title: "AbCip — CIP multi-tag write packing" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + AbCipDriver.WriteAsync loops over writes one-by-one. Group writes by device and submit one CIP Multi-Service Packet (0x0A) carrying up to N write-singles per round-trip. Honours per-family SupportsRequestPacking. May require new IAbCipTagRuntime.WriteBatchAsync. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiWritePlanner.cs] + docs: [docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1, scripts/smoke/seed-abcip-smoke.sql] + effort: L + deps: [] + cross_driver: false + notes: "May need raw CIP via @raw if libplctag lacks multi-write." +- id: abcip-2.1 + driver: abcip + phase: 2 + plan_pr_id: "2.1" + title: "AbCip — L5K parser + ingest" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Parse Studio 5000 L5K export (TAG/END_TAG, DATATYPE/END_DATATYPE, program-scope qualifiers). Produce AbCipTagDefinition + AbCipStructureMember records. Hook into AbCipDriverOptions.TagImports parsed on InitializeAsync. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs] + docs: [docs/drivers/AbCip-TagImport.md, docs/Driver.AbCip.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/Import/Fixtures/] + e2e: [scripts/e2e/test-abcip.ps1] + effort: L + deps: [] + cross_driver: false + notes: "" +- id: abcip-2.2 + driver: abcip + phase: 2 + plan_pr_id: "2.2" + title: "AbCip — L5X (XML) parser + ingest" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Same surface as 2.1 but parses Studio 5000 XML export with richer metadata (ExternalAccess, AOI). Share IL5kSource/L5kIngest layer with 2.1 via a common ParsedTagsBundle record. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs] + docs: [docs/drivers/AbCip-TagImport.md, docs/Driver.AbCip.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/Import/Fixtures/] + e2e: [scripts/e2e/test-abcip.ps1] + effort: L + deps: [abcip-2.1] + cross_driver: false + notes: "" +- id: abcip-2.3 + driver: abcip + phase: 2 + plan_pr_id: "2.3" + title: "AbCip — Tag descriptions surfaced as OPC UA Description" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Extend AbCipTagDefinition with Description, populate from L5K/L5X parsers, thread to DriverAttributeInfo so the address-space builder sets OPC UA Description attribute. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs, src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs] + docs: [docs/drivers/AbCip-TagImport.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: S + deps: [abcip-2.1, abcip-2.2] + cross_driver: false + notes: "" +- id: abcip-2.4 + driver: abcip + phase: 2 + plan_pr_id: "2.4" + title: "AbCip — CSV tag import / export" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + CSV round-trip matching Kepware column layout. Import populates AbCipTagDefinition; export dumps live tag table for Excel editing. CsvTagImporter, CsvTagExporter, CLI tag-export command. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs] + docs: [docs/drivers/AbCip-TagImport.md, docs/Driver.AbCip.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/Import/Fixtures/] + e2e: [scripts/e2e/test-abcip.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: abcip-2.5 + driver: abcip + phase: 2 + plan_pr_id: "2.5" + title: "AbCip — Online tag-DB refresh trigger" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Implement IDriverControl.RebrowseAsync to force re-walk of controller symbol table without driver restart. Expose via CLI and AbCipDriver.RebrowseAsync. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/] + docs: [docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: abcip-2.6 + driver: abcip + phase: 2 + plan_pr_id: "2.6" + title: "AbCip — AOI input/output handling" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + AOI-aware browse paths: AOI instance shows up as folder with Inputs/Outputs/InOut sub-folders. AbCipStructureMember gains AoiQualifier enum; L5K/L5X parser populates it. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStructureMember.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/drivers/AbCip-TagImport.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/Import/Fixtures/] + e2e: [] + effort: M + deps: [abcip-2.2] + cross_driver: false + notes: "" +- id: abcip-3.1 + driver: abcip + phase: 3 + plan_pr_id: "3.1" + title: "AbCip — Configurable CIP Connection Size per device" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Add ConnectionSize field to AbCipDeviceOptions overriding family default (4002/504/488). Thread to libplctag connection_size attribute. Range 500-4002. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs] + docs: [docs/drivers/AbCip-Performance.md, docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [scripts/smoke/seed-abcip-smoke.sql] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: abcip-3.2 + driver: abcip + phase: 3 + plan_pr_id: "3.2" + title: "AbCip — Symbolic vs logical (instance-ID) addressing toggle" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + AddressingMode enum (Symbolic/Logical/Auto). Logical flips libplctag into instance-ID mode for ~3-5x throughput. Requires symbol-table walk on init. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/drivers/AbCip-Performance.md, docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: L + deps: [] + cross_driver: false + notes: "" +- id: abcip-3.3 + driver: abcip + phase: 3 + plan_pr_id: "3.3" + title: "AbCip — Logical-blocking / non-blocking strategy selector" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + ReadStrategy enum (WholeUdt / MultiPacket / Auto). MultiPacket bundles per-member reads via libplctag request-packing for sparse UDTs. Honours SupportsRequestPacking. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/drivers/AbCip-Performance.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [] + effort: L + deps: [abcip-1.4] + cross_driver: false + notes: "" +- id: abcip-4.1 + driver: abcip + phase: 4 + plan_pr_id: "4.1" + title: "AbCip — Per-tag scan rate / scan group bucketing" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Optional ScanRate field on AbCipTagDefinition overrides subscription interval. PollGroupEngine new Subscribe overload for per-tag overrides. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/drivers/AbCip-Operability.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: abcip-4.2 + driver: abcip + phase: 4 + plan_pr_id: "4.2" + title: "AbCip — Write deadband / write-on-change" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Per-tag WriteDeadband / WriteOnChange. Track last-written value; suppress next write if delta < deadband. Suppressed writes return Good for OPC UA semantics. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipWriteCoalescer.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/drivers/AbCip-Operability.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: abcip-4.3 + driver: abcip + phase: 4 + plan_pr_id: "4.3" + title: "AbCip — Diagnostic / system tags as browseable variables" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Surface IHostConnectivityProbe + DriverHealth as browseable variables under AbCip//_System/. Variables _ConnectionStatus, _ScanRate, _TagCount, _DeviceError, _LastScanTimeMs. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs] + docs: [docs/drivers/AbCip-Operability.md, docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: abcip-4.4 + driver: abcip + phase: 4 + plan_pr_id: "4.4" + title: "AbCip — _RefreshTagDb writeable system tag" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + Writeable system tag wiring: writes to _RefreshTagDb dispatch to RebrowseAsync (PR 2.5). + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/drivers/AbCip-Operability.md, docs/Driver.AbCip.Cli.md] + fixture: [] + e2e: [scripts/e2e/test-abcip.ps1] + effort: S + deps: [abcip-2.5, abcip-4.3] + cross_driver: false + notes: "" +- id: abcip-5.1 + driver: abcip + phase: 5 + plan_pr_id: "5.1" + title: "AbCip — HSBY paired-IP probing" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + AbCipDeviceOptions.PartnerHostAddress; both gateways probed concurrently. AbCipHsbyRoleProber reads the role tag (WallClockTime.SyncStatus or S:34) returning Active/Standby. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipHsbyRoleProber.cs] + docs: [docs/drivers/AbCip-HSBY.md, docs/Driver.AbCip.Cli.md, docs/drivers/AbServer-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml] + e2e: [] + effort: L + deps: [] + cross_driver: false + notes: "HSBY role tag varies by firmware." +- id: abcip-5.2 + driver: abcip + phase: 5 + plan_pr_id: "5.2" + title: "AbCip — IPerCallHostResolver failover routing" + plan_anchor: "docs/plans/abcip-plan.md" + summary: | + AbCipDriver.ResolveHost returns the currently-Active partner. On role transition the existing per-host breaker isolates a stuck primary. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs] + docs: [docs/drivers/AbCip-HSBY.md] + fixture: [] + e2e: [scripts/e2e/test-abcip-hsby.ps1] + effort: M + deps: [abcip-5.1] + cross_driver: false + notes: "" +- id: ablegacy-1 + driver: ablegacy + phase: 1 + plan_pr_id: "1" + title: "AbLegacy — PLC-5 octal I/O addressing" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Add family-aware TryParse for octal I:/O: addressing on PLC-5 (octal in RSLogix 5; today silently accepted as decimal). AbLegacyPlcFamilyProfile gains OctalIoAddressing flag. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs] + docs: [docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-ablegacy.ps1] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: ablegacy-2 + driver: ablegacy + phase: 1 + plan_pr_id: "2" + title: "AbLegacy — MicroLogix function-file letters" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Recognise multi-letter function files (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) when family is MicroLogix. Add SupportsFunctionFiles flag and per-type sub-element catalogue. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs] + docs: [docs/drivers/AbLegacy-MicroLogix-FunctionFiles.md, docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-ablegacy.ps1] + effort: M + deps: [ablegacy-1] + cross_driver: false + notes: "" +- id: ablegacy-3 + driver: ablegacy + phase: 1 + plan_pr_id: "3" + title: "AbLegacy — Sub-element bit semantics for Timer/Counter/Control" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + .DN/.EN/.TT/.CU/.CD/.OV/.UN/.ER surface as Boolean. EffectiveDriverDataType helper; ReadAsync decodes parent word + masks bit. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs] + docs: [docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-ablegacy.ps1, scripts/smoke/seed-ablegacy-smoke.sql] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: ablegacy-4 + driver: ablegacy + phase: 1 + plan_pr_id: "4" + title: "AbLegacy — Indirect / indexed addressing parser" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Accept N7:[N7:0] (read N7 word indexed by N7:0) and N[N7:0]:5. Nullable IndirectFileSource / IndirectWordSource on AbLegacyAddress, depth 1. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs] + docs: [docs/drivers/AbLegacy-Indirect-Addressing.md, docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-ablegacy.ps1] + effort: M + deps: [ablegacy-1] + cross_driver: false + notes: "" +- id: ablegacy-5 + driver: ablegacy + phase: 2 + plan_pr_id: "5" + title: "AbLegacy — PD/MG/PLS/BT structure files" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Add PD (PID), MG (Message), PLS (Programmable Limit Switch), BT (Block Transfer) file types. New enum members + per-type sub-element catalogue (SP/PV/CV/KP/KI/KD as Float; EN/DN/MO/PE as Boolean). + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs] + docs: [docs/drivers/AbLegacy-Structure-Files.md, docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-ablegacy.ps1, scripts/smoke/seed-ablegacy-smoke.sql] + effort: M + deps: [ablegacy-3] + cross_driver: false + notes: "" +- id: ablegacy-6 + driver: ablegacy + phase: 2 + plan_pr_id: "6" + title: "AbLegacy — ST string read/write production verification" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Verify libplctag's GetString/SetString handles ST 82-byte length-word format. Round-trip 82/0/41-char, embedded null, non-ASCII, both Slc500 and Plc5 families. BadOutOfRange on over-length writes. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs, tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs, tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyStringEncodingTests.cs] + docs: [docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-ablegacy.ps1, scripts/smoke/seed-ablegacy-smoke.sql] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: ablegacy-7 + driver: ablegacy + phase: 3 + plan_pr_id: "7" + title: "AbLegacy — Array contiguous block addressing" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Expose array tags with IsArray=true + ArrayDim, backed by libplctag elem_count=N. Parser accepts ,N and [N] suffixes. Target ≥5x latency vs individual tags. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs] + docs: [docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-ablegacy.ps1, scripts/smoke/seed-ablegacy-smoke.sql] + effort: M + deps: [ablegacy-1] + cross_driver: false + notes: "" +- id: ablegacy-8 + driver: ablegacy + phase: 3 + plan_pr_id: "8" + title: "AbLegacy — Per-tag deadband / change filter" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + AbsoluteDeadband and PercentDeadband per tag. Per-tag last-published-value cache, deadband test wraps PollGroupEngine callback. Booleans bypass deadband. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs] + docs: [docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-ablegacy.ps1, scripts/smoke/seed-ablegacy-smoke.sql] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: ablegacy-9 + driver: ablegacy + phase: 4 + plan_pr_id: "9" + title: "AbLegacy — Per-device timeout / retry overrides" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + AbLegacyDeviceOptions.Timeout / Retries. SLC 5/01 ~5s vs 5/05 ~2s vs ML1100 ~3s. Driver-wide AbLegacyDriverOptions.Timeout becomes default. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs] + docs: [docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [] + e2e: [scripts/smoke/seed-ablegacy-smoke.sql] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: ablegacy-10 + driver: ablegacy + phase: 4 + plan_pr_id: "10" + title: "AbLegacy — Diagnostic counters as tags" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Per-device counters (RequestCount, ResponseCount, ErrorCount, RetryCount, LastErrorCode, LastErrorMessage, CommFailures) as auto-generated _Diagnostics/* variables. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs] + docs: [docs/drivers/AbLegacy-Diagnostics.md, docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-ablegacy.ps1, scripts/smoke/seed-ablegacy-smoke.sql] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: ablegacy-11 + driver: ablegacy + phase: 4 + plan_pr_id: "11" + title: "AbLegacy — RSLogix 500/PLC-5 symbol import" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + v1 scopes to .SLC/.CSV text exports (Symbol,Address,Description,DataType,Scope). RsLogixSymbolImport, IRsLogixImporter abstraction, AddRsLogixImport extension method, import-rslogix CLI subcommand. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Import/RsLogixSymbolImport.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Import/IRsLogixImporter.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs] + docs: [docs/drivers/AbLegacy-RSLogix-Import.md, docs/Driver.AbLegacy.Cli.md, docs/DriverClis.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/rslogix-canonical.csv] + e2e: [scripts/e2e/test-ablegacy.ps1] + effort: L + deps: [ablegacy-1, ablegacy-2, ablegacy-3, ablegacy-4, ablegacy-5] + cross_driver: false + notes: "" +- id: ablegacy-12 + driver: ablegacy + phase: 5 + plan_pr_id: "12" + title: "AbLegacy — Auto-demote on comm failure" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + AbLegacyDemoteOptions { FailureThreshold=3, DemoteFor=30s, Enabled=true }. ConsecutiveFailures, DemotedUntilUtc on DeviceState. Adds HostState.Demoted enum value. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs] + docs: [docs/drivers/AbLegacy-AutoDemote.md, docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-ablegacy.ps1, scripts/smoke/seed-ablegacy-smoke.sql] + effort: M + deps: [ablegacy-10] + cross_driver: false + notes: "" +- id: ablegacy-13 + driver: ablegacy + phase: 5 + plan_pr_id: "13" + title: "AbLegacy — DH+ via 1756-DHRIO bridging" + plan_anchor: "docs/plans/ablegacy-plan.md" + summary: | + Validate and document CIP path for DH+ bridging: 1,,2,. Surface BackplaneSlot/DhPlusPort/DhPlusStation. Plc5-only on family profile. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs] + docs: [docs/drivers/AbLegacy-DH-Bridging.md, docs/Driver.AbLegacy.Cli.md, docs/drivers/AbLegacy-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-ablegacy.ps1] + effort: S + deps: [ablegacy-1] + cross_driver: false + notes: "Hardware-gated; can't simulate DHRIO." +- id: focas-f1a + driver: focas + phase: 1 + plan_pr_id: "F1-a" + title: "FOCAS — ODBST status flags as fixed-tree nodes" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Project 9 fields of cnc_rdcncstat (tmmode/aut/run/motion/mstb/emergency/alarm/edit/dummy) under Status/. Existing boolean probe preserved; full-struct cached on every poll tick. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/drivers/FOCAS-Test-Fixture.md, docs/v2/implementation/focas-wire-protocol.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: focas-f1b + driver: focas + phase: 1 + plan_pr_id: "F1-b" + title: "FOCAS — Parts count + cycle time" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Surface cnc_rdparam(6711/6712/6713) under Production/, plus Production/CycleTimeSeconds promoted from Timers/CycleSeconds. Pure projection-layer addition. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs] + docs: [docs/drivers/FOCAS.md, docs/Driver.FOCAS.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/profiles/] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: focas-f1c + driver: focas + phase: 1 + plan_pr_id: "F1-c" + title: "FOCAS — Modal G/M/T codes + override values" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + cnc_modal projecting Modal/G_Group{n} (groups 1..21), Modal/MCode/SCode/TCode/BCode, Override/Feed/Rapid/Spindle/Jog from cnc_rdparam. Per-device operator overrides for MTB-specific cases. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/Driver.FOCAS.Cli.md, docs/v2/focas-version-matrix.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: focas-f1d + driver: focas + phase: 1 + plan_pr_id: "F1-d" + title: "FOCAS — Tool number / tool life + work coordinate offsets" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + cnc_rdtofs / cnc_rdtlife* / cnc_rdzofs. Project Tooling/CurrentTool, Tooling/CurrentOffset, Tooling/Life/{group}/{Remaining,Total}, Offsets/G54..G59[+ extended]/{X,Y,Z}. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/focas-version-matrix.md, docs/drivers/FOCAS-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [] + effort: L + deps: [] + cross_driver: false + notes: "" +- id: focas-f1e + driver: focas + phase: 1 + plan_pr_id: "F1-e" + title: "FOCAS — Operator messages + currently-executing block text" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + cnc_rdopmsg3 (all four FANUC opmsg classes) and cnc_rdactpt (current block text). Messages/External as ring-buffer; Program/CurrentBlock as string. Trim-stable round-trip. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/implementation/focas-wire-protocol.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: focas-f1f + driver: focas + phase: 1 + plan_pr_id: "F1-f" + title: "FOCAS — cnc_getfigure scaling + connection statistics" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Cache per-axis decimal-place counts; divide position values before publishing. Diagnostics/ subtree (ReadCount, ReadFailureCount, LastErrorMessage, LastSuccessfulRead, ReconnectCount). FixedTree.ApplyFigureScaling flag. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/focas-deployment.md, docs/drivers/FOCAS-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [scripts/e2e/test-focas.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: focas-f2a + driver: focas + phase: 2 + plan_pr_id: "F2-a" + title: "FOCAS — DIAG: address scheme" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + New FocasAreaKind.Diagnostic parsed from DIAG:nnn / DIAG:nnn/axis, dispatched to cnc_rddiag (or cnc_rddiagdgn). DiagnosticRange added to capability matrix. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs] + docs: [docs/Driver.FOCAS.Cli.md, docs/drivers/FOCAS.md, docs/v2/focas-version-matrix.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: focas-f2b + driver: focas + phase: 2 + plan_pr_id: "F2-b" + title: "FOCAS — Multi-path / multi-channel CNC" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Optional Path segment in FocasAddress (PARAM:1815@2, R100@3.0, MACRO:500@2). Per-device PathCount via cnc_rdpathnum. Defaults to PathId=1 for back-compat. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAddress.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/Driver.FOCAS.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [scripts/e2e/test-focas.ps1] + effort: L + deps: [] + cross_driver: false + notes: "" +- id: focas-f2c + driver: focas + phase: 2 + plan_pr_id: "F2-c" + title: "FOCAS — PMC F/G letters for 16i" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Capability-matrix correctness: PmcLetters(Sixteen_i) currently {X,Y,R,D}; widen to include F/G. Real 16i ladders use F/G for handshakes. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs] + docs: [docs/v2/focas-version-matrix.md] + fixture: [] + e2e: [scripts/e2e/test-focas.ps1] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: focas-f2d + driver: focas + phase: 2 + plan_pr_id: "F2-d" + title: "FOCAS — Bulk PMC range read coalescing" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Coalescer groups same-letter contiguous PMC bytes from request batch into one wire call per group. Cap ≤256 bytes per range. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/implementation/focas-wire-protocol.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [] + effort: M + deps: [focas-f1f] + cross_driver: false + notes: "" +- id: focas-f3a + driver: focas + phase: 3 + plan_pr_id: "F3-a" + title: "FOCAS — cnc_rdalmhistry alarm-history extension" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + FocasAlarmProjection ActiveOnly | ActivePlusHistory. On connect + cadence (default 5min) issue cnc_rdalmhistry. Dedup key (timestamp, alarmNumber, type). + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasAlarmProjection.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/focas-deployment.md, docs/drivers/FOCAS-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: focas-f4a + driver: focas + phase: 4 + plan_pr_id: "F4-a" + title: "FOCAS — Write infrastructure + per-tag opt-in" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Drop BadNotWritable short-circuit; kind-based dispatch. Honour FocasTagDefinition.Writable (default false). Driver-level Writes.Enabled (default false). Revokes read-only callouts in 3 user-facing docs. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/WireFocasClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/drivers/FOCAS-Test-Fixture.md, docs/Driver.FOCAS.Cli.md, docs/v2/decisions.md, docs/v2/focas-deployment.md, docs/featuregaps.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [scripts/e2e/test-focas.ps1] + effort: M + deps: [] + cross_driver: false + notes: "Phase 4 writes; revokes read-only design." +- id: focas-f4b + driver: focas + phase: 4 + plan_pr_id: "F4-b" + title: "FOCAS — cnc_wrmacro + cnc_wrparam" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Macro and parameter writes. Granular Writes.AllowParameter / Writes.AllowMacro. ACL: WriteConfigure for parameters, WriteOperate for macros. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/WireFocasClient.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/focas-deployment.md, docs/Driver.FOCAS.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [scripts/e2e/test-focas.ps1] + effort: M + deps: [focas-f4a] + cross_driver: false + notes: "Phase 4 writes." +- id: focas-f4c + driver: focas + phase: 4 + plan_pr_id: "F4-c" + title: "FOCAS — pmc_wrpmcrng" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + PMC range writes with read-modify-write for bit-level (wire is byte-addressed). Writes.AllowPmc granular flag. Loud safety callout. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/WireFocasClient.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/focas-deployment.md, docs/Driver.FOCAS.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [scripts/e2e/test-focas.ps1] + effort: M + deps: [focas-f4a] + cross_driver: false + notes: "Phase 4 writes." +- id: focas-f4d + driver: focas + phase: 4 + plan_pr_id: "F4-d" + title: "FOCAS — Password / unlock parameter" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Password on FocasDeviceOptions (redacted in logs). cnc_wrunlockparam during connect. Re-issue + retry once on EW_PASSWD. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/focas-deployment.md, docs/Driver.FOCAS.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/] + e2e: [scripts/e2e/test-focas.ps1] + effort: S + deps: [focas-f4a, focas-f4b] + cross_driver: false + notes: "" +- id: focas-f5a + driver: focas + phase: 5 + plan_pr_id: "F5-a" + title: "FOCAS — Cycle time per part / last cycle delta" + plan_anchor: "docs/plans/focas-plan.md" + summary: | + Production/LastCycleSeconds and Production/LastCycleStartUtc derived from CycleSeconds delta on parts-count increments. Pure derivation; no new wire calls. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs] + docs: [docs/drivers/FOCAS.md, docs/v2/focas-deployment.md] + fixture: [] + e2e: [] + effort: S + deps: [focas-f1b] + cross_driver: false + notes: "" +- id: opcuaclient-1 + driver: opcuaclient + phase: 1 + plan_pr_id: "1" + title: "OpcUaClient — Per-subscription tuning" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Lift hard-coded subscription parameters (KeepAliveCount=10, LifetimeCount=1000, MaxNotificationsPerPublish=0, Priority=0, PublishingInterval floor 50ms) into OpcUaClientDriverOptions.Subscriptions. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Client.CLI.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-2 + driver: opcuaclient + phase: 1 + plan_pr_id: "2" + title: "OpcUaClient — Per-tag advanced subscription tuning incl. deadband" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Surface SamplingInterval, QueueSize, DiscardOldest, MonitoringMode, DataChangeFilter (DeadbandType=Absolute/Percent + Trigger) per-tag. Extend ISubscribable with additive overload accepting MonitoredTagSpec list. + files: [src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Client.CLI.md, docs/Client.UI.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: L + deps: [opcuaclient-1] + cross_driver: false + notes: "" +- id: opcuaclient-3 + driver: opcuaclient + phase: 1 + plan_pr_id: "3" + title: "OpcUaClient — Honor server OperationLimits" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Read Server.ServerCapabilities.OperationLimits.MaxNodesPerRead/Write/Browse/HistoryReadData via Session.FetchOperationLimitsAsync; cache; chunk batch operations client-side. Treat zero as no limit. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md] + fixture: [] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-4 + driver: opcuaclient + phase: 1 + plan_pr_id: "4" + title: "OpcUaClient — Diagnostics counters" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Per-driver counters (publish-request, notifications-per-second EWMA, missing-publish-request, dropped-notification, session-resets) on DriverHealth/sibling DriverDiagnostics. Hook Session events. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs, src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs] + docs: [docs/drivers/OpcUaClient.md] + fixture: [] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-5 + driver: opcuaclient + phase: 1 + plan_pr_id: "5" + title: "OpcUaClient — CRL / revocation handling" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Explicit revoked-cert handling in CertificateValidator; RejectSHA1SignedCertificates, RejectUnknownRevocationStatus, MinimumCertificateKeySize options. Surface revoked vs untrusted distinctly. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs] + docs: [docs/drivers/OpcUaClient.md, docs/security.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-6 + driver: opcuaclient + phase: 2 + plan_pr_id: "6" + title: "OpcUaClient — Discovery URL FindServers" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Accept discovery URL pointing at LDS or server's discovery endpoint. DiscoveryClient.CreateAsync + FindServersAsync + GetEndpointsAsync. DiscoveryUrl knob feeds failover candidate list. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Client.CLI.md] + fixture: [] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-7 + driver: opcuaclient + phase: 2 + plan_pr_id: "7" + title: "OpcUaClient — Selective import / namespace remap" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Per-branch include/exclude rules, namespace-URI remapping, re-keyed BrowseNames. Curation section in options. Glob: * and ? only. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md, docs/drivers/OpcUaClient-Test-Fixture.md] + fixture: [] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-8 + driver: opcuaclient + phase: 2 + plan_pr_id: "8" + title: "OpcUaClient — Type definition mirroring" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Walk upstream Types folder (ObjectTypes, VariableTypes, DataTypes, ReferenceTypes); project into local address space. Pass-3 in DiscoverAsync that walks i=86 under curation. Session.NodeCache.FetchNode, Session.LoadDataTypeSystem, Session.FetchTypeTree. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs, src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Client.UI.md] + fixture: [] + e2e: [] + effort: L + deps: [opcuaclient-7] + cross_driver: false + notes: "" +- id: opcuaclient-9 + driver: opcuaclient + phase: 2 + plan_pr_id: "9" + title: "OpcUaClient — Method node mirroring + Call passthrough" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Discover NodeClass.Method nodes; expose locally; forward Call invocations as Session.CallAsync. Adds new IMethodInvoker capability interface (9th capability). Browse HasProperty for InputArguments/OutputArguments. + files: [src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Client.CLI.md, docs/Client.UI.md] + fixture: [] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: L + deps: [] + cross_driver: false + notes: "9th capability interface." +- id: opcuaclient-10 + driver: opcuaclient + phase: 3 + plan_pr_id: "10" + title: "OpcUaClient — Auto re-import on ModelChangeEvent" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Subscribe BaseModelChangeEventType / GeneralModelChangeEventType on i=2253 Server; debounced re-discover (2-5s window). WatchModelChanges, ModelChangeDebounce options. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs] + docs: [docs/drivers/OpcUaClient.md] + fixture: [] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-11 + driver: opcuaclient + phase: 4 + plan_pr_id: "11" + title: "OpcUaClient — Reverse Connect" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Server-initiated client connect for OT-DMZ outbound-only firewalls. ReverseConnectManager singleton listener; Session.Create accepting ITransportWaitingConnection. ReverseConnect options (Enabled, ListenerUrl, ExpectedServerUri). + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md, docs/drivers/OpcUaClient-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: L + deps: [] + cross_driver: false + notes: "Listen-vs-dial direction change; highest-risk PR." +- id: opcuaclient-12 + driver: opcuaclient + phase: 5 + plan_pr_id: "12" + title: "OpcUaClient — IHistoryProvider.ReadEventsAsync interface fix + impl" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Extend IHistoryProvider.ReadEventsAsync with EventFilter SelectClauses parameter. Implement OPC UA Client passthrough using Session.HistoryReadAsync with ReadEventDetails. Touches Core.Abstractions; affects Galaxy IHistoryProvider impl. + files: [src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs, src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Client.CLI.md] + fixture: [] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: L + deps: [] + cross_driver: true + notes: "Cross-driver: Core.Abstractions interface change; Galaxy override updates." +- id: opcuaclient-13 + driver: opcuaclient + phase: 5 + plan_pr_id: "13" + title: "OpcUaClient — Full Aggregate function set" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Extend HistoryAggregateType from 5 to 30+ Part 13 aggregates (TimeAverage, Interpolative, Range, PercentGood/Bad, StandardDeviation*, Variance*, Start/End/Delta, DurationInState*, etc.). Pure mapping work. + files: [src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Client.CLI.md] + fixture: [] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: opcuaclient-14 + driver: opcuaclient + phase: 5 + plan_pr_id: "14" + title: "OpcUaClient — ServerUriArray redundant failover" + plan_anchor: "docs/plans/opcuaclient-plan.md" + summary: | + Read upstream Server.ServerArray + ServerRedundancyType.RedundancySupport. Subscribe Server_ServiceLevel; on low ServiceLevel open parallel session and TransferSubscriptionsAsync. Redundancy options block. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs] + docs: [docs/drivers/OpcUaClient.md, docs/Redundancy.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml] + e2e: [scripts/e2e/test-opcuaclient.ps1] + effort: L + deps: [] + cross_driver: false + notes: "" +- id: s7-a1 + driver: s7 + phase: 1 + plan_pr_id: "PR-S7-A1" + title: "S7 — 64-bit scalar types (LInt/ULInt/LReal/LWord)" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Closes the NotSupportedException cliff for Float64/Int64/UInt64. Extend S7Size with LWord; teach parser to accept DBL/LD suffix. S7netplus has no native LD; falls back to byte-range reads with explicit big-endian conversion. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py, tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/profiles/s7_1500.json] + e2e: [scripts/e2e/test-s7.ps1] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: s7-a2 + driver: s7 + phase: 1 + plan_pr_id: "PR-S7-A2" + title: "S7 — STRING / WSTRING / CHAR / WCHAR" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + S7 STRING (2-byte header + ASCII), WSTRING (4-byte header + UTF-16BE), CHAR, WCHAR codecs. Header-bug clamp on read; reject on write. New S7DataType members; respect StringLength. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py] + e2e: [scripts/e2e/test-s7.ps1] + effort: M + deps: [s7-a1] + cross_driver: false + notes: "" +- id: s7-a3 + driver: s7 + phase: 1 + plan_pr_id: "PR-S7-A3" + title: "S7 — DTL / DT / S5TIME / TIME / TOD / DATE" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + DTL (12-byte), DATE_AND_TIME (8-byte BCD), S5TIME (16-bit BCD), TIME (Int32 ms), TOD (UInt32 ms), DATE (UInt16 days since 1990). New S7DateTimeCodec.cs static class. Golden byte vectors from STEP 7 V18 reference. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py] + e2e: [] + effort: L + deps: [s7-a1] + cross_driver: false + notes: "" +- id: s7-a4 + driver: s7 + phase: 1 + plan_pr_id: "PR-S7-A4" + title: "S7 — Array tags (ValueRank=1)" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + S7TagDefinition gets ArrayDim/ElementCount. Read/write path issues one byte-range read covering N elements; client-side slicing. Multi-dim deferred. Array-of-UDT lands with PR-S7-D2. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py] + e2e: [scripts/e2e/test-s7.ps1] + effort: M + deps: [s7-a1] + cross_driver: false + notes: "" +- id: s7-a5 + driver: s7 + phase: 1 + plan_pr_id: "PR-S7-A5" + title: "S7 — LOGO! 8 + S7-200 V-memory" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Teach S7AddressParser to accept V area letter, mapping to S7Area.DataBlock with DbNumber=1 for S7-200 (and per-firmware VM-mapping for LOGO!). + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: s7-b1 + driver: s7 + phase: 2 + plan_pr_id: "PR-S7-B1" + title: "S7 — Multi-variable PDU packing" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Replace per-tag plc.ReadAsync loop with packer using S7.Net.Types.DataItem + plc.ReadMultipleVarsAsync. Per-item errors fan out to per-tag StatusCodes. Packing budget: negotiatedPduSize - 18 - 12·N. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md] + fixture: [] + e2e: [] + effort: L + deps: [] + cross_driver: false + notes: "" +- id: s7-b2 + driver: s7 + phase: 2 + plan_pr_id: "PR-S7-B2" + title: "S7 — Block-read coalescing for contiguous DBs" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Planner pass groups same-DB tags by contiguous byte ranges; one Plc.ReadBytes per range. Default gap-merge 16 bytes (configurable). STRING/array tags opt out. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md] + fixture: [] + e2e: [] + effort: M + deps: [s7-b1] + cross_driver: false + notes: "" +- id: s7-c1 + driver: s7 + phase: 3 + plan_pr_id: "PR-S7-C1" + title: "S7 — PDU size negotiation surfaced" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Read Plc.MaxPDUSize after OpenAsync; expose via GetHealth().Diagnostics["NegotiatedPduSize"]. Coordinate with Modbus diagnostic surface for Core DriverHealth.Diagnostics dictionary. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md, docs/drivers/S7-Test-Fixture.md] + fixture: [] + e2e: [] + effort: S + deps: [] + cross_driver: true + notes: "Core DriverHealth.Diagnostics shape change." +- id: s7-c2 + driver: s7 + phase: 3 + plan_pr_id: "PR-S7-C2" + title: "S7 — TSAP / Connection Type selector" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + TsapMode enum (Auto/Pg/Op/S7Basic/Other) + LocalTsap/RemoteTsap overrides. Auto preserves current behaviour. Branches into raw-TSAP Plc constructor. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md] + fixture: [] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: s7-c3 + driver: s7 + phase: 3 + plan_pr_id: "PR-S7-C3" + title: "S7 — Per-tag scan group / publish rate" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + S7TagDefinition.ScanGroup string. SubscribeAsync partitions into one poll loop per distinct interval (driver-local PollGroupEngine). _gate semaphore still serializes; document fairness-queue follow-up. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md] + fixture: [] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: s7-c4 + driver: s7 + phase: 3 + plan_pr_id: "PR-S7-C4" + title: "S7 — Deadband / on-change with thresholds" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + DeadbandAbsolute and DeadbandPercent options on S7TagDefinition. Per-tag in PollOnceAsync for numeric types; non-numeric falls to exact equality. Edge cases: NaN, Infinity, sign flip, near-zero percent. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md] + fixture: [] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: s7-c5 + driver: s7 + phase: 3 + plan_pr_id: "PR-S7-C5" + title: "S7 — Pre-flight PUT/GET enablement test" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Pre-flight read during InitializeAsync against MW0 / Probe.ProbeAddress. On PUT/GET-disabled PlcException, throw S7PutGetDisabledException with config hint. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "" +- id: s7-d1 + driver: s7 + phase: 4 + plan_pr_id: "PR-S7-D1" + title: "S7 — Symbol-table / TIA Portal export browse" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + SymbolImport directory: TiaCsvImporter (TIA Portal "Show all tags" CSV) and AwlImporter (best-effort STEP 7 Classic). Locale-comma TIA exports detected from header. Admin UI button + POST endpoint. UDT-typed symbols import as placeholders. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/TiaCsvImporter.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/AwlImporter.cs] + docs: [docs/drivers/S7-TIA-Import.md, docs/v2/s7.md, docs/Driver.S7.Cli.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Fixtures/] + e2e: [] + effort: L + deps: [] + cross_driver: false + notes: "" +- id: s7-d2 + driver: s7 + phase: 4 + plan_pr_id: "PR-S7-D2" + title: "S7 — UDT / STRUCT / nested-DB handling" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + S7TagDefinition.UdtName + IReadOnlyList Udts. Driver fans UDT-typed tags into per-member sub-tags. Caps UDT-of-UDT at 4 levels. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md, docs/drivers/S7-TIA-Import.md, docs/drivers/S7-Test-Fixture.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py] + e2e: [scripts/e2e/test-s7.ps1] + effort: L + deps: [s7-d1] + cross_driver: false + notes: "" +- id: s7-d3 + driver: s7 + phase: 4 + plan_pr_id: "PR-S7-D3" + title: "S7 — Instance-DB / FB parameter access" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Extend TIA importer to recognize "instance DB" entries; translate MyFB_Instance.MyParam → DBn.DBW_offset using FB interface declaration. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/TiaCsvImporter.cs] + docs: [docs/drivers/S7-TIA-Import.md, docs/v2/s7.md] + fixture: [tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Fixtures/] + e2e: [] + effort: M + deps: [s7-d1, s7-d2] + cross_driver: false + notes: "" +- id: s7-e1 + driver: s7 + phase: 5 + plan_pr_id: "PR-S7-E1" + title: "S7 — CPU diagnostic buffer / SZL reads" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Virtual @System.* tags dispatched via S7netplus's ReadSzlAsync. @System.CpuType/Firmware/OrderNo (SZL 0x0011), @System.CycleMs.{Min,Max,Avg} (SZL 0x0132/0x0432), @System.DiagBuffer[0..N] (SZL 0x00A0). + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [] + e2e: [] + effort: L + deps: [s7-c1] + cross_driver: false + notes: "" +- id: s7-e2 + driver: s7 + phase: 5 + plan_pr_id: "PR-S7-E2" + title: "S7 — PLC password / protection-level handling" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Password and ProtectionLevel options. SendPassword call (with raw-PDU fallback per Siemens Communication Function Manual §5.2). Wire path gated on library support. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs, src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs] + docs: [docs/v2/s7.md, docs/Driver.S7.Cli.md, docs/drivers/S7-Test-Fixture.md] + fixture: [] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- id: s7-f + driver: s7 + phase: 6 + plan_pr_id: "PR-S7-F" + title: "S7 — Optimized DB / S7Plus (decision PR)" + plan_anchor: "docs/plans/s7-plan.md" + summary: | + Architectural decision PR. Three tracks: (1) document constraint and stay on S7netplus, (2) migrate to S7Plus-capable lib, (3) bridge via OPC UA Client driver against S7-1500's onboard UA server. Recommendation: ship Track 1 docs + Track 3 workflow path. + files: [] + docs: [docs/v2/s7.md, docs/featuregaps.md, docs/drivers/S7-Test-Fixture.md] + fixture: [] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "Architectural decision PR." +- 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. Remove the truncation comment "matches Int64 gap". May add Int64 to Core.Abstractions DriverDataType enum. + 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] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "Hardware-gated TWINCAT_TARGET_NETID." +- 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; convert to native UA Duration/DateTime via post-processing in ReadValueAsync, ConvertForWrite, OnAdsNotificationEx. May expose Duration in DriverDataType. + 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] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "Hardware-gated." +- id: twincat-1.3 + driver: twincat + phase: 1 + plan_pr_id: "1.3" + title: "TwinCAT — Bit-indexed BOOL writes (RMW)" + plan_anchor: "docs/plans/twincat-plan.md" + summary: | + Replace NotSupportedException at AdsTwinCATClient.cs:99 with read-modify-write on parent word; serialize concurrent bit writes via keyed SemaphoreSlim. + 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: "" +- 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: | + Whole-array reads in single AdsClient call. Surface IsArray + ArrayDimensions on TwinCATTagDefinition + DriverAttributeInfo. + 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] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "Hardware-gated." +- 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: | + Inspect symbol.DataType + Category from TwinCAT.TypeSystem; DataTypeCategory.Enum walks EnumValues; Alias resolves to base atomic recursively. POINTER/REFERENCE/INTERFACE/UNION 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: [] + e2e: [] + effort: M + deps: [] + cross_driver: false + notes: "" +- 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 SumSymbolRead/SumSymbolWrite (IndexGroup 0xF080-0xF084). Bucket by DeviceHostAddress. Targets ~10x throughput. Perf-tier test gated 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] + e2e: [] + effort: L + deps: [] + cross_driver: false + notes: "Perf-tier gated." +- 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. On DeviceSymbolVersionInvalid (0x710) evict and retry once. Clear on AdsClient reconnect until 2.3 ships. + 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: "" +- 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: | + AddDeviceNotificationAsync on AdsReservedIndexGroup.SymbolVersion (0xF008); wipe handle cache on online-change bumps. + files: [src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs] + docs: [docs/drivers/TwinCAT-Test-Fixture.md] + fixture: [] + e2e: [] + effort: M + deps: [twincat-2.2] + cross_driver: false + notes: "Hardware-gated." +- 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: | + NotificationSettings.MaxDelay as per-tag option (default 0). Plumb int? MaxDelayMs through TwinCATTagDefinition, SubscribeAsync, AddNotificationAsync. + 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: [] + e2e: [] + effort: S + deps: [] + cross_driver: false + notes: "Hardware-gated." +- 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: | + Probe loop reads _AppInfo.OnlineChangeCnt/AppName + _TaskInfo[1].CycleTime/LastExecTime; surface as TwinCATDeviceDiagnostics on DeviceState; emit through IDriverDiagnostics (cross-driver from Modbus task #154). + 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: [] + e2e: [] + effort: M + deps: [] + cross_driver: true + notes: "Reuses IDriverDiagnostics from Modbus #154." +- 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: | + Recurse BrowseSymbolsAsync into IStructType.SubItems yielding one TwinCATDiscoveredSymbol per leaf with dotted instance paths. Expand arrays-of-structs up to 1024. Online runtime path only — TMC offline parsing deferred. + 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/] + e2e: [] + effort: L + deps: [twincat-1.5] + cross_driver: false + notes: "Hardware-gated." +- 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: | + IAlarmSource over TcEventLogger on AMS port 110 so PLC alarms surface as OPC UA AC events. Spike-first to determine managed wrapper vs raw AMS port 110. 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] + e2e: [scripts/e2e/test-twincat.ps1] + effort: L + deps: [] + cross_driver: false + notes: "Spike-first." diff --git a/scripts/queue/setup-labels.sh b/scripts/queue/setup-labels.sh new file mode 100644 index 0000000..4128ed0 --- /dev/null +++ b/scripts/queue/setup-labels.sh @@ -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 - </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') +" diff --git a/scripts/queue/start-pr.sh b/scripts/queue/start-pr.sh new file mode 100644 index 0000000..3d1d218 --- /dev/null +++ b/scripts/queue/start-pr.sh @@ -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" diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json index cbb842a..39fddd1 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json @@ -1,6 +1,6 @@ { "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": { "Ldap": { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs index fe17ff9..826ff55 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// Reachability probe for the ab_server Docker container (libplctag's CIP /// simulator built via Docker/Dockerfile) or any real AB PLC the /// AB_SERVER_ENDPOINT env var points at. Parses -/// AB_SERVER_ENDPOINT (default localhost:44818) + TCP-connects +/// AB_SERVER_ENDPOINT (default 10.100.0.35:44818) + TCP-connects /// once at fixture construction. Tests skip via /// / when the port isn't live, so /// dotnet test 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. 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 AbServerFixture() : this(KnownProfiles.ControlLogix) { } @@ -59,7 +59,7 @@ public sealed class AbServerFixture : IAsyncLifetime TcpProbe(ResolveHost(), ResolvePort()); 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() { @@ -84,7 +84,7 @@ public sealed class AbServerFixture : IAsyncLifetime /// /// [Fact]-equivalent that skips when ab_server isn't reachable — accepts a -/// live Docker listener on localhost:44818 or an AB_SERVER_ENDPOINT +/// live Docker listener on 10.100.0.35:44818 or an AB_SERVER_ENDPOINT /// override pointing at a real PLC. /// public sealed class AbServerFactAttribute : FactAttribute diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml index a9b56f2..a614238 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml @@ -17,6 +17,8 @@ services: dockerfile: Dockerfile image: otopcua-ab-server:libplctag-release container_name: otopcua-ab-server-controllogix + labels: + project: lmxopcua restart: "no" ports: - "44818:44818" @@ -40,6 +42,8 @@ services: context: . dockerfile: Dockerfile container_name: otopcua-ab-server-compactlogix + labels: + project: lmxopcua restart: "no" ports: - "44818:44818" @@ -63,6 +67,8 @@ services: context: . dockerfile: Dockerfile container_name: otopcua-ab-server-micro800 + labels: + project: lmxopcua restart: "no" ports: - "44818:44818" @@ -82,6 +88,8 @@ services: context: . dockerfile: Dockerfile container_name: otopcua-ab-server-guardlogix + labels: + project: lmxopcua restart: "no" ports: - "44818:44818" diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml index d083ddc..fcc9d99 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml @@ -17,6 +17,8 @@ services: dockerfile: Dockerfile image: otopcua-pymodbus:3.13.0 container_name: otopcua-pymodbus-standard + labels: + project: lmxopcua restart: "no" ports: - "5020:5020" @@ -34,6 +36,8 @@ services: context: . dockerfile: Dockerfile container_name: otopcua-pymodbus-dl205 + labels: + project: lmxopcua restart: "no" ports: - "5020:5020" @@ -51,6 +55,8 @@ services: context: . dockerfile: Dockerfile container_name: otopcua-pymodbus-mitsubishi + labels: + project: lmxopcua restart: "no" ports: - "5020:5020" @@ -68,6 +74,8 @@ services: context: . dockerfile: Dockerfile container_name: otopcua-pymodbus-s7_1500 + labels: + project: lmxopcua restart: "no" ports: - "5020:5020" @@ -91,6 +99,8 @@ services: context: . dockerfile: Dockerfile container_name: otopcua-modbus-exception-injector + labels: + project: lmxopcua restart: "no" ports: - "5020:5020" diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs index 14f6472..edfd57b 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs @@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; /// /// Reachability probe for a Modbus TCP simulator (pymodbus in Docker, see /// Docker/docker-compose.yml) or a real PLC. Parses -/// MODBUS_SIM_ENDPOINT (default localhost:5020 per PR 43) and TCP-connects once at +/// MODBUS_SIM_ENDPOINT (default 10.100.0.35:5020) and TCP-connects once at /// fixture construction. Each test checks and calls /// Assert.Skip 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 @@ -30,7 +30,7 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable // 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 // 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"; public string Host { get; } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml index 18849ce..6839d73 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml @@ -8,6 +8,8 @@ services: opc-plc: image: mcr.microsoft.com/iotedge/opc-plc:2.14.10 container_name: otopcua-opc-plc + labels: + project: lmxopcua restart: "no" ports: - "50000:50000" diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs index 856a12a..030bafe 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; /// Reachability probe for an opc-plc simulator (Microsoft Industrial IoT's /// OPC UA PLC from mcr.microsoft.com/iotedge/opc-plc) or any real OPC UA /// server the OPCUA_SIM_ENDPOINT env var points at. Parses -/// OPCUA_SIM_ENDPOINT (default opc.tcp://localhost:50000), +/// OPCUA_SIM_ENDPOINT (default opc.tcp://10.100.0.35:50000), /// TCP-connects to the resolved host:port at collection init, and records a /// on failure. Tests call Assert.Skip on that, so /// `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; /// 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"; /// Full opc.tcp://host:port URL the driver session should connect to. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml index 03bb9e7..6e29d6e 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml @@ -14,6 +14,8 @@ services: dockerfile: Dockerfile image: otopcua-python-snap7:1.0 container_name: otopcua-python-snap7-s7_1500 + labels: + project: lmxopcua restart: "no" ports: - "1102:1102" diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs index e7dbc22..7b5421f 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Snap7ServerFixture.cs @@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests; /// /// Reachability probe for the python-snap7 simulator Docker container (see /// Docker/docker-compose.yml) or a real S7 PLC. Parses S7_SIM_ENDPOINT -/// (default localhost:1102) + TCP-connects once at fixture construction. +/// (default 10.100.0.35:1102) + TCP-connects once at fixture construction. /// Tests check + call Assert.Skip when unreachable, so /// `dotnet test` stays green on a fresh box without the simulator installed — /// mirrors the ModbusSimulatorFixture pattern. @@ -35,7 +35,7 @@ public sealed class Snap7ServerFixture : IAsyncDisposable { // Default 1102 (non-privileged) matches Docker/server.py. Override with // 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"; public string Host { get; }