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

44 KiB

AbCip Driver — Implementation Plan

Source of gap analysis: featuregaps.md → AbCip

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)?