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

Two distinct buckets:

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

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

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

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

683 lines
44 KiB
Markdown

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