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>
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
Int32widening atAbCipDataType.cs:53withInt64routing across decode + encode + theDriverDataTypemap. IncludesDT(epoch-millis on Logix v32+ surfaces as LINT, not DINT — verify againstLibplctagTagRuntime.cs:53before 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 intoInt32),Core.Abstractions/DriverDataType.csmay need anInt64/UInt64member if not already present. - Test approach: unit (xUnit + Shouldly) with a fake
IAbCipTagRuntimethat returnslong.MaxValueonDecodeValueAt(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.Int64exists; if not, that is a Core change shared with the Modbus TODO atAbCipDataType.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); extendsdocs/drivers/AbServer-Test-Fixture.md§"What it actually covers" to listLINTonce ab_server is reseeded with aTestLINT:LINT[1]tag; updates thetests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.ymlControlLogix profile to seedTestLINT; adds a 64-bit assertion case inAbCipReadSmokeTests; extendsscripts/e2e/test-abcip.ps1with an LInt loopback assertion (and a matching seededTestLINTinscripts/smoke/seed-abcip-smoke.sql).
PR 1.2 — Native STRING / STRINGnn variant decoding
- Scope: Today
AbCipDataType.Stringflattens any LogixSTRINGUDT into a .NET string via libplctag's_tag.GetString(0). Logix programs commonly defineSTRING_20,STRING_40,STRING_80variants 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 genericStringplaceholder. Add aStringLengthfield toAbCipStructureMember+AbCipTagDefinitionso declared variants carry their cap, and thread it into theTag.Nameattribute or a libplctag string-cap hint. - Files:
AbCipDataType.cs,AbCipDriverOptions.cs(record fields),LibplctagTagRuntime.cs(string-length aware decode/encode), and the discovery emit atAbCipDriver.cs:715. - Test approach: unit test with a fake runtime returning
stringvalues 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_bytesattributes — the docs reference them but the C# wrapper may not expose them; if not, this PR must extendLibplctagTagRuntimewith a raw-buffer decode path. - Docs / fixture / e2e: extends
docs/Driver.AbCip.Cli.mdwith a new--string-sizeflag in theread/writecookbook plus a STRINGnn worked example; updatesdocs/drivers/AbServer-Test-Fixture.md§"What it actually covers" to listSTRING_20/STRING_80once seeded; extends the ControlLogix profile intests/.../Docker/docker-compose.ymlwithTestSTRING80:STRING[1](plus aSTRING_20variant ifab_serverhonours non-default DATA caps; otherwise documented as Emulate-tier only); addstests/.../IntegrationTests/AbCipStringDecodingTests.csround-trip; adds a short-string round-trip case toscripts/e2e/test-abcip.ps1and aTestSTRING80row toscripts/smoke/seed-abcip-smoke.sql.
PR 1.3 — Array-slice read addressing Tag[0..N]
- Scope: today
AbCipTagPathparsesTag[3,5]as a single element. Add slice syntaxTag[0..15](parsed inAbCipTagPath.TryParse) and a planner that issues one libplctag read withelem_count=Nper Rockwell array semantics, decoding the buffer at element stride into N output snapshots. Mirrors the whole-UDT planner pattern. - Files:
AbCipTagPath.cs(parser — addIsSlice+SliceLengthto the path segment record, or carry it onAbCipTagPathitself), newAbCipArrayReadPlanner.csnext toAbCipUdtReadPlanner.cs,AbCipDriver.ReadAsyncto dispatch through the planner,IAbCipTagRuntimeto addDecodeArrayAt(type, elementStride, count)or build onDecodeValueAt. Investigate libplctag'selem_countattribute onTagcreate 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.mdreadsection with theTag[0..N]slice syntax + a worked example readingRecipe[0..15]in one round-trip; updatesdocs/drivers/AbServer-Test-Fixture.md§"What it actually covers" to mention the existingDINT[16]array tag is now exercised end-to-end via slicing; extendsAbCipReadSmokeTestswith a slice-read assertion against the seededTestDINTArray; addstests/.../IntegrationTests/AbCipArraySliceTests.cscovering edge cases (boundary, single-element, full-range); adds a slice-read assertion toscripts/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-familySupportsRequestPackingflag atAbCipPlcFamilyProfile.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), newAbCipMultiWritePlanner.cs, possibly a newIAbCipTagRuntime.WriteBatchAsyncmethod or a newIAbCipMultiWritercapability since libplctag's high-levelTag.WriteAsyncis per-tag — investigate libplctag'scip-msg-multiraw-CIP path or whether building a Multi-Service Packet viaplc_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
@rawpseudo-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; updatesdocs/drivers/AbServer-Test-Fixture.md§7 ("Capability surfaces beyond read") to flipIWritable.WriteAsyncfrom "no smoke test" to covered for the multi-write path; addstests/.../IntegrationTests/AbCipMultiWriteTests.csasserting 50-tag batch lands in one round-trip (count via libplctag debug-trace sink); extendsscripts/e2e/test-abcip.ps1with 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_TAGblocks,DATATYPE ... END_DATATYPEUDT definitions, and program-scope qualifiers). ProduceAbCipTagDefinition+AbCipStructureMemberrecords that match the declarative options shape. Includes Description ingest (PR 2.3 lifts it to OPC UADescription). - Files: new
Import/L5kParser.cs, newImport/IL5kSource.csfor testability, newImport/L5kIngest.csthat converts parsed records intoAbCipTagDefinition. Hook intoAbCipDriverOptionsvia a newTagImportscollection (filenames or inline blobs) parsed onAbCipDriver.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.mdcovering the L5K format support matrix (controller-scope / program-scope / UDT / alias-skipped) and a worked example of pointingAbCipDriverOptions.TagImportsat an L5K export; appends atag-importcommand section todocs/Driver.AbCip.Cli.md(CLI gainstag-import --file foo.L5K); fixture-side no change toab_server(offline parse — no PLC needed) but adds sample L5K files undertests/.../AbCip.Tests/Import/Fixtures/; extendsscripts/e2e/test-abcip.ps1with an offlinetag-importsmoke 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.csusingSystem.Xml.XPath, share theIL5kSource/L5kIngestingest layer with PR 2.1 by introducing a commonParsedTagsBundlerecord. - 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 thetag-importCLI section indocs/Driver.AbCip.Cli.mdto note L5X auto-detection by file extension; sample L5X files added undertests/.../AbCip.Tests/Import/Fixtures/(one with an AOI-typed tag for PR 2.6); reuses the offlinetag-importstep fromscripts/e2e/test-abcip.ps1(now driven by L5X) — no fixture container change because parse is offline; cross-links fromtests/.../IntegrationTests/LogixProject/README.mdso the on-site Emulate L5X export doubles as a parser fixture.
PR 2.3 — Tag descriptions surfaced as OPC UA Description
- Scope: extend
AbCipTagDefinitionwithDescription(string?), populate it from the L5K/L5X parsers, and thread it through toDriverAttributeInfoso the address-space builder sets the OPC UADescriptionattribute. Also lifts the description ontoAbCipStructureMemberfor member-level metadata. - Files:
AbCipDriverOptions.cs(record fields),AbCipDriver.cs:760-770(ToAttributeInfohelper),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
DriverAttributeInforecord. - 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.mddocumenting how Studio 5000 descriptions surface as OPC UADescription; no CLI surface change (read-side only — the existingotopcua-cli readalready projectsDescription); no fixture container change; adds a cross-driver assertion to the existing OPC UA browse test intests/.../IntegrationTests/verifying the description survives the full parser → driver → server → client path; extendsscripts/e2e/test-abcip.ps1with a one-lineDescription != nullassertion 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 populatesAbCipTagDefinition; export dumps the live tag table for editing in Excel. - Files: new
Import/CsvTagImporter.cs, newImport/CsvTagExporter.cs, integration point inAbCipDriverOptions.TagImportsparallel to PR 2.1's hook. Export hook is exposed via the CLI (docs/Driver.AbCip.Cli.md) — add atag-exportcommand. - 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
ParsedTagsBundleshape. - Docs / fixture / e2e: appends a "CSV tag table" section to
docs/drivers/AbCip-TagImport.mddocumenting the column layout (Kepware-compatible) and round-trip semantics; appendstag-exportand CSV-flavourtag-importcommands todocs/Driver.AbCip.Cli.md; adds sample CSVs undertests/.../AbCip.Tests/Import/Fixtures/plus a CLI integration test (tests/.../AbCip.Tests/Import/CsvRoundTripTests.cs); extendsscripts/e2e/test-abcip.ps1with 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$UpdateTagInfoso an HMI can write1to force the driver to re-walk the controller's symbol table after a Studio 5000 download — without restarting the driver. Implement as a newIDriverControl.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 newAbCipDriver.RebrowseAsyncmethod. - Files:
AbCipDriver.cs(new method that re-runs the@tagsenumerator without going through fullReinitializeAsync), CLI command insrc/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/, documentation update indocs/Driver.AbCip.Cli.md. - Test approach: unit test that two consecutive
RebrowseAsynccalls 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
rebrowsecommand todocs/Driver.AbCip.Cli.mdwith a Studio 5000 download recipe ("after a download, runotopcua-abcip-cli rebrowse -g …"); cross-references the future_RefreshTagDbsystem tag once PR 4.4 lands; updatesdocs/drivers/AbServer-Test-Fixture.md§7 to markITagDiscovery.DiscoverAsyncas covered for the rebrowse path; addstests/.../IntegrationTests/AbCipRebrowseTests.csdriving two consecutive enumerations (the second sees a tag added between calls — ab_server supports runtime reseed via its REST hook); extendsscripts/e2e/test-abcip.ps1with 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 (
AddOnInstructionDefinitionblocks). The Template Object decoder atCipTemplateObjectDecoder.cslikely 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 withInputs/,Outputs/,InOut/sub-folders; (b) skip-on-discovery forInOutparameters per Kepware's documented limitation (InOut is a pointer, not a value). - Files: extend
AbCipStructureMemberwith anAoiQualifierenum (Input/Output/InOut/Local), L5K/L5X parser extends to set it,AbCipDriver.DiscoverAsyncgroups 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.mdcovering Inputs/Outputs/InOut grouping + the InOut skip rationale; updatesdocs/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 undertests/.../AbCip.Tests/Import/Fixtures/and a discovery test that asserts the Inputs/Outputs sub-folder shape; promotestests/.../IntegrationTests/Emulate/AbCipEmulateAoiTests.cs(gated onAB_SERVER_PROFILE=emulate) — noscripts/e2e/test-abcip.ps1change 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 optionalConnectionSizefield toAbCipDeviceOptionsthat 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(extendAbCipDeviceOptionsrecord),IAbCipTagRuntime.cs(extendAbCipTagCreateParamswithConnectionSize),LibplctagTagRuntime.cs(set theTag.PlcType-adjacent attribute — investigate libplctag C# wrapper's exposure ofconnection_size; may need to set viaTag.AddAttributeif 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_sizeattribute 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 indocs/Driver.AbCip.Cli.mdfor the new per-device option in the Driver config; updatesdocs/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; extendsscripts/smoke/seed-abcip-smoke.sqlwith aConnectionSizefield demo; noscripts/e2e/test-abcip.ps1change (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-deviceAddressingModeenum (Symbolic | Logical | Auto) and thread it throughAbCipTagCreateParams.Autois the default and matches today's behaviour;Logicalflips libplctag into instance-ID mode. Logical mode requires a one-time symbol-table walk to map names to instance IDs — reuseLibplctagTagEnumeratorfor 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 rawcip_addrattribute. - Docs / fixture / e2e: appends an "Addressing mode" section to
docs/drivers/AbCip-Performance.md(Symbolic / Logical / Auto trade-offs); adds a per-deviceaddressing-modeknob todocs/Driver.AbCip.Cli.md(CLI gains--addressing-modeonread/subscribe/writefor ad-hoc benchmarking); updatesdocs/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 testtests/.../IntegrationTests/AbCipAddressingModeBenchTests.cs; extendsscripts/e2e/test-abcip.ps1with 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-deviceReadStrategyenum 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), andAuto(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), newAbCipMultiPacketReadPlanner.cs,AbCipDriver.ReadAsyncdispatch. HonoursAbCipPlcFamilyProfile.SupportsRequestPackingat the family level so a user-selectedMultiPacketon 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.mdcovering WholeUdt / MultiPacket / Auto plus the sparsity-threshold heuristic; updatesdocs/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; addstests/.../IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs(gated onAB_SERVER_PROFILE=emulate); no CLI surface change beyond the existing per-device option, noscripts/e2e/test-abcip.ps1change 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
publishingIntervalper_poll.Subscribe(...)call. Add an optionalScanRatefield toAbCipTagDefinitionthat, when set, overrides the subscription interval for that tag. The sharedPollGroupEnginealready 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).PollGroupEnginemay need a newSubscribe(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
PollGroupEnginein 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; addstests/.../IntegrationTests/AbCipPerTagScanRateTests.csdriving two tags at different rates against ab_server and asserting bucket separation; extendsscripts/e2e/test-abcip.ps1with a two-tag subscribe-rate-divergence assertion.
PR 4.2 — Write deadband / write-on-change
- Scope:
AbCipDriver.WriteAsyncwrites every request through. Add per-tagWriteDeadband(numeric) andWriteOnChange(boolean). When set, the driver tracks the last successfully-written value per(tag, deviceHostAddress)and suppresses the next write if|new - last| < deadband(numeric) ornew == last(any). Suppressed writes returnGoodso OPC UA semantics are unaffected. - Files:
AbCipDriverOptions.cs(record fields), newAbCipWriteCoalescer.csholding the per-tag last-value cache,AbCipDriver.WriteAsyncconsults 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.mdwith a worked setpoint-jitter example; updatesdocs/drivers/AbServer-Test-Fixture.md§7 to flip the multi-write coverage line to also cover suppression; addstests/.../IntegrationTests/AbCipWriteDeadbandTests.csdriving a jittery setpoint and asserting the actual PLC write count via libplctag debug-trace; extendsscripts/e2e/test-abcip.ps1with 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+DriverHealthdata as browseable OPC UA variables underAbCip/<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; newAbCipSystemTagSource.csproduces the live values;ReadAsyncroutes_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 —
_RefreshTagDbwrites1to invokeRebrowseAsync. - Docs / fixture / e2e: appends a "System tags /
_Systemfolder" section todocs/drivers/AbCip-Operability.mdenumerating_ConnectionStatus,_ScanRate,_TagCount,_DeviceError,_LastScanTimeMs; cross-link fromdocs/Driver.AbCip.Cli.md(thereadcookbook gains a system-tag example); updatesdocs/drivers/AbServer-Test-Fixture.md§7 to flipIHostConnectivityProbestate- transition coverage from "no" to covered (system tag observation provides the assertion hook); addstests/.../IntegrationTests/AbCipSystemTagDiscoveryTests.cs; extendsscripts/e2e/test-abcip.ps1with a_System/_ConnectionStatusbrowse-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
RebrowseAsyncmethod. - Files:
AbCipSystemTagSource.cs(writeable variable),AbCipDriver.WriteAsyncintercepts_RefreshTagDbwrites and dispatches toRebrowseAsync. - Test approach: unit + CLI integration.
- Effort: S
- Dependencies: PR 2.5 and PR 4.3.
- Docs / fixture / e2e: extends the
_Systemtable indocs/drivers/AbCip-Operability.mdto mark_RefreshTagDbas writeable; appends a "Refreshing the tag DB" recipe todocs/Driver.AbCip.Cli.mdthat pairs the system-tag write with the existingrebrowsecommand from PR 2.5; reuses theAbCipRebrowseTestsfixture from PR 2.5 with an added system-tag-write entry point; extendsscripts/e2e/test-abcip.ps1with a_RefreshTagDbwrite-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
AbCipDeviceOptionswithPartnerHostAddress(optional). When set, the device probes both gateways concurrently using the existing probe loop machinery (AbCipDriver.cs:235-281). A ControlLogix HSBY pair exposesWallClockTime/Module.Statustags that identify the active chassis — investigate the exact tag name;WallClockTime.SyncStatusis one option,S:34(Module Status) carries the role bit on some versions. - Files:
AbCipDriverOptions.cs(extendAbCipDeviceOptions),AbCipDriver.cs(extendDeviceStatewithActiveAddressfield, run two probe loops), newAbCipHsbyRoleProber.csreading 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.mdthat 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.mdcovering the paired-IP config, the role-tag detection matrix (v20 / v24 / v32+), and the feature-flag gate (Redundancy.Hsby.Enabled); extendsdocs/Driver.AbCip.Cli.mdwith a--partnerflag plus anhsby-statuscommand that prints the active partner; updatesdocs/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 totests/.../Docker/docker-compose.yml(twocontrollogixservices on different ports + ahsby-muxsidecar that flips the role bit on demand); addstests/.../IntegrationTests/AbCipHsbyRoleProberTests.cs; noscripts/e2e/test-abcip.ps1change yet — HSBY e2e is gated behind a siblingscripts/e2e/test-abcip-hsby.ps1script introduced in PR 5.2.
PR 5.2 — Failover routing in IPerCallHostResolver
- Scope:
AbCipDriver.ResolveHostreturns 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:ResolveHostconsultsDeviceState.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
ResolveHostoutput; 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.mddocumenting 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; addstests/.../IntegrationTests/AbCipHsbyFailoverTests.csdriving the role-flip via thehsby-muxsidecar and asserting reads route to the new active partner; ships the newscripts/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.DriverDataTypealready hasInt64; 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
IAbCipReadPlannerseam 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
@rawsend, 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 acrossReinitializeAsyncif 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.mdand 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
OnDataChangeevents 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); onceResolveHostreturns 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-sizeflag (PR 1.2), slice syntax (PR 1.3), multi-write subsection (PR 1.4),tag-import/tag-exportcommands (PR 2.1, PR 2.2, PR 2.4),rebrowsecommand (PR 2.5), Connection Size note (PR 3.1),--addressing-modeflag (PR 3.2), system-tag read example (PR 4.3),_RefreshTagDbrecipe (PR 4.4),--partnerflag plushsby-statuscommand (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 seededTestLINT,TestSTRING80, extra DINT tags for multi-write (PRs 1.1, 1.2, 1.4); a new paired-fixture mode withhsby-muxsidecar for HSBY (PR 5.1).tests/.../AbCip.IntegrationTests/AbServerProfile.cs— theKnownProfilesrecords 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 existingAB_SERVER_PROFILE=emulategate.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),_Systembrowse-and-read (4.3),_RefreshTagDbwrite-then- verify (4.4).scripts/smoke/seed-abcip-smoke.sql— extended withTestLINT,TestSTRING80, multi-write target tags, and aConnectionSizefield 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 intoscripts/e2e/test-all.ps1until HSBY exits feature-flag gating.
Cross-cutting work
- The
Docs / fixture / e2elines deliberately reuse the existingTest-Probe/Test-DriverLoopback/Test-ServerBridge/Test-OpcUaWriteBridge/Test-SubscribeSeesChangehelpers inscripts/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.csmay need a small extension in PR 5.1 to support the paired-port probe; the change is additive (probe both127.0.0.1:44818and127.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
- libplctag instance-ID API (PR 3.2) — does the C# wrapper expose
logical_segment/cip_addrattributes directly, or do we have to drop down toTag.AddAttributecalls? Affects scope of Phase 3. - libplctag CIP Multi-Service Packet (PR 1.4) — is there a wrapper-level multi-write
helper, or must we go through the
@rawpseudo-tag? Affects scope of Phase 1. DriverDataType.Int64/Int64Array(PR 1.1) — does Core already carry it, or is this a shared Core change with Modbus's matching TODO?- 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.
- 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.
- 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.
- 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.
- Per-tag scan rate plumbing (PR 4.1) — does
PollGroupEnginein Core already accept per-reference interval overrides, or does that need a Core extension shared with the other polling-overlay drivers (Modbus, FOCAS)?