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