Files
lmxopcua/docs/plans/ablegacy-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

41 KiB
Raw Blame History

AbLegacy Driver — Implementation Plan

Source of gap analysis: featuregaps.md → AbLegacy

Covers Build = Yes items only. Skip-rated gaps listed at bottom for traceability.

Summary

The AbLegacy driver (PCCC over EtherNet/IP via libplctag) currently ships with parsing for the canonical SLC/PLC-5/MicroLogix file letters, four PLC-family profiles, bit-within-N-word RMW writes, a probe loop, and a flat static-config tag list. The featuregaps.md Recommendations table flags 13 gaps as Build = Yes:

  1. DH+ via 1756-DHRIO bridging (#2)
  2. PD/MG/PLS/BT files (#5)
  3. PLC-5 octal addressing (#7)
  4. Indirect/indexed addressing (#8)
  5. Array contiguous block addressing (#9)
  6. ST string read/write production verification (#10)
  7. Sub-element bit semantics (.DN as Bit) (#11)
  8. Auto-demote on comm failure (#13)
  9. RSLogix 500/5 symbol import (#15)
  10. Per-tag deadband / change filter (#18)
  11. Diagnostic counters as tags (#20)
  12. Per-device timeout / retry overrides (#21)
  13. MicroLogix function-file naming (RTC/HSC/DLS) (#23)

The plan splits these across 5 phases / 13 PRs (one PR per gap, with a couple of small ones bundled). Phases are ordered by coupling — addressing correctness first because everything downstream depends on the parser, then file/type coverage, then performance, then workflow tooling, then resilience. Each PR is sized to fit comfortably under the project's per-PR review budget (most S/M; only the RSLogix import is L).

Phased delivery

Phase Theme PRs Gaps
1 Addressing correctness 4 #7 octal, #8 indirect, #11 sub-element bits, #23 ML function files
2 File / type coverage 2 #5 PD/MG/PLS/BT, #10 ST verification
3 Performance 2 #9 array block, #18 per-tag deadband
4 Workflow 3 #15 RSLogix import, #21 per-device timeouts, #20 diagnostic counters
5 Resilience 2 #13 auto-demote, #2 DH+ bridging

Phase 1 lands first because Phase 2 (PD/MG/PLS/BT) and Phase 3 (array reads) both extend the parser shipped in Phase 1. Phase 5 (auto-demote) reads diagnostic counters from Phase 4 #20, so 4 precedes 5.


Per-PR detail

Phase 1 — Addressing correctness

PR 1 — PLC-5 octal I/O addressing (#7)

Scope: PLC-5 documentation and RSLogix 5 use octal for I: / O: word and bit indices (I:001/17 is rack 0 group 0 word 1, bit 17₈ = bit 15₁₀). Today AbLegacyAddress.TryParse does int.TryParse on the word number and bit index, silently accepting decimal. For PlcFamily=Plc5 (and only that family) I / O files must parse as octal.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs — add TryParse(string, AbLegacyPlcFamily) overload; existing TryParse(string) keeps decimal semantics (back-compat for non-PLC-5 callers and pure shape validation).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.csEnsureTagRuntimeAsync and the bit-RMW path call the family-aware overload using device.Options.PlcFamily.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs — add OctalIoAddressing flag (true for Plc5 only).

Test plan:

  • Unit (tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs): I:001/17 parses to word=1, bit=15 under PLC-5; same string parses to bit=17 under SLC500. O:7/10 (decimal under SLC500 = bit 10; octal under PLC-5 = bit 8).
  • Round-trip: ToLibplctagName() must emit the format libplctag expects (verify libplctag's PLC-5 PCCC layer accepts octal-formatted I/O addresses, or whether we must convert decimal→octal-text before forwarding).

Docs / fixture / e2e:

  • Update docs/Driver.AbLegacy.Cli.md — extend the "PCCC address primer" with an I: / O: row noting PLC-5 octal vs SLC500 decimal semantics; worked example showing I:001/17 resolved differently per family.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — note octal-vs-decimal addressing as a covered family-aware parser dimension under the unit-coverage list.
  • Fixture: extend tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml plc5 profile to seed an I:001 (or equivalent module-image word) tag if ab_server --plc=PLC/5 accepts it; otherwise document the gap in Docker/README.md.
  • E2E: add --plc-type Plc5 -a "I:001/17" octal-bit assertion to scripts/e2e/test-ablegacy.ps1 (gated on the plc5 compose profile being up); no change to scripts/smoke/seed-ablegacy-smoke.sql required (existing N7:5 tag continues to cover the SLC500 path).

Effort: S Dependencies: none


PR 2 — MicroLogix function-file letters (RTC / HSC / DLS / MMI / PTO / PWM / STI / EII / IOS / BHI) (#23)

Scope: MicroLogix 1100/1400 expose proprietary function files that don't share file letters with SLC. Today IsKnownFileLetter (AbLegacyAddress.cs:97-101) only allows the SLC/PLC-5 set, so any tag like RTC:0.HR is rejected at parse time even though libplctag's micrologix PlcType supports them.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs — extend IsKnownFileLetter to recognise multi-letter function-file types (RTC, HSC, DLS, MMI, PTO, PWM, STI, EII, IOS, BHI). Permit only when family is MicroLogix. The letter-scan loop already accepts any contiguous letters (AbLegacyAddress.cs:80-82).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs — define a sub-element catalogue per function-file (RTC has YR/MON/DAY/HR/MIN/SEC/DOW; HSC has ACC/HIP/LOP/OFS/etc.). Map each sub-element to the right DriverDataType.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.csSupportsFunctionFiles flag.

Test plan:

  • Unit: RTC:0.HR parses with FileLetter="RTC", WordNumber=0, SubElement="HR". HSC:0.ACC parses. Same strings under PlcFamily=Slc500 must reject (ML1100 file types not present on SLC).
  • Integration (tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests): only if a MicroLogix simulator profile exists; flag as TODO otherwise — verify libplctag micrologix PlcType accepts these tag names.

Docs / fixture / e2e:

  • New doc docs/drivers/AbLegacy-MicroLogix-FunctionFiles.md — catalogue of supported function files (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI), per-family availability matrix (ML1100 vs ML1400 vs ML1500), sub-element-to-DriverDataType table.
  • Update docs/Driver.AbLegacy.Cli.md — add a "MicroLogix function files" row to the PCCC address primer with RTC:0.HR / HSC:0.ACC examples and a CLI worked example.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — record fixture coverage status for function files and link to the micrologix profile gap (only if ab_server --plc=Micrologix rejects function-file addresses, document the unit-only fallback).
  • Fixture: extend tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml micrologix profile with --tag=RTC0[1] / --tag=HSC0[1] if accepted by ab_server, else mark as hardware-gated in Docker/README.md.
  • E2E: add a parametric -PlcType MicroLogix -Address RTC:0.HR invocation to scripts/e2e/test-ablegacy.ps1 (skip-when-fixture-gap, mirroring the existing BadCommunicationError gate); no seed-ablegacy-smoke.sql change unless the fixture supports function-file tags.

Effort: M Dependencies: PR 1 (parser overload signature settled)


PR 3 — Sub-element bit semantics (.DN, .EN, .TT, .CU, .CD, .OV, .UN, .ER) (#11)

Scope: Today T4:0.DN parses fine but the TimerElement/CounterElement/ControlElement types collapse to Int32 (AbLegacyDataType.cs:41-44). HMIs expect .DN / .EN / .TT / .CU / .CD / .OV / .UN / .ER to surface as Boolean. The fix is to detect the sub-element at tag-runtime build time and override the driver-surface type.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs — new helper SubElementBitNames (HashSet of bit-typed sub-elements per parent type — Timer: EN/TT/DN; Counter: CU/CD/DN/OV/UN; Control: EN/EU/DN/EM/ER/UL/IN/FD). New EffectiveDriverDataType(AbLegacyDataType, string? subElement) returning Boolean for bit-typed sub-elements, otherwise the existing mapping.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.csDiscoverAsync uses EffectiveDriverDataType(def.DataType, parsed.SubElement); ReadAsync decodes the parent word and masks the bit instead of returning the whole word as Int32.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs — verify libplctag exposes .DN etc. as a single bit when read with GetBit against the sub-element address. If not, fall back to read-the-word + mask.

Test plan:

  • Unit (AbLegacyDriverTests + new AbLegacyDataTypeTests): T4:0.DN discovers as Boolean; T4:0.ACC discovers as Int32; counter .OV is Boolean; control .LEN is Int32.
  • Bit-write semantics: writing Boolean true to T4:0.DN should be rejected with BadNotWritable (timer status bits are PLC-set; verify by integration smoke test against the AbLegacy simulator).

Docs / fixture / e2e:

  • Update docs/Driver.AbLegacy.Cli.md — extend the Timer/Counter/Control rows in the address primer with a "bit sub-elements surface as Boolean" note and a --type Bool -a T4:0.DN CLI example.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — note AbLegacyDataTypeTests as a new unit-coverage class under "What it actually covers".
  • Fixture: no compose change required (T4/C5/R6 already seeded by ab_server defaults — verify; if not, add --tag=T4[5]/--tag=C5[5]/--tag=R6[5] to the slc500 profile in Docker/docker-compose.yml).
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with a Boolean sub-element read assertion (read --type Bool -a T4:0.DN) once the simulator round-trip works. Update scripts/smoke/seed-ablegacy-smoke.sql to add a Boolean tag binding T4:0.DN so the server-bridge assertion exercises the new mapping.

Effort: M Dependencies: none (independent of PR 1/2 parser changes)


PR 4 — Indirect / indexed addressing parser (N7:[N7:0], N[N7:0]:5) (#8)

Scope: Recipe / batch lookup tables use N7:[N7:0] (read N7 word indexed by the value at N7:0) or N[N7:0]:5. Today AbLegacyAddress.TryParse rejects both because it requires literal integer word and file numbers.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs — record gains nullable IndirectFileSource and IndirectWordSource (each itself an AbLegacyAddress). Parser handles [<inner>] segments at file-number or word-number positions. Recursion depth capped at 1 (libplctag accepts only one level of indirection per address — verify against libplctag PCCC docs).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs — no change.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs — pass-through; ToLibplctagName() re-emits the bracket form.

Test plan:

  • Unit: N7:[N7:0] → outer file=N7, indirect word source = (N, 7, 0); B3:[N7:0]/0 → bit, indirect word source = (N, 7, 0); N[N7:0]:5 → indirect file source = (N, 7, 0), word=5; depth-2 (N[N[N7:0]:5]:0) must reject.
  • Integration: verify libplctag's slc500/plc5 PlcType accepts a Name of form N7:[N7:0] and resolves at read time. (If libplctag rejects indirect text, fall back to two-step read: resolve the inner address, then read the outer with the resolved index. Document the chosen strategy in the PR.)

Docs / fixture / e2e:

  • New doc docs/drivers/AbLegacy-Indirect-Addressing.md — explain N7:[N7:0] and N[N7:0]:5 syntax, the depth-1 limit, the chosen libplctag strategy (verbatim pass-through vs two-step resolve), and recipe-table use cases.
  • Update docs/Driver.AbLegacy.Cli.md — add an indirect-addressing row to the address primer with --address "N7:[N7:0]" example.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — under unit coverage, list AbLegacyAddressTests indirect-parsing cases.
  • Fixture: no Docker/docker-compose.yml change required (N7[10] already seeded; the inner index tag at N7:0 is already addressable). Document recipe-pattern in Docker/README.md.
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with an indirect-address driver-loopback case (write to N7:0 to set the index, then read N7:[N7:0] and assert the value matches the previously-written content of the resolved word). Skip-gate behind libplctag capability check.

Effort: M Dependencies: PR 1 (octal resolution must apply to inner address too if the outer file is I:/O: on PLC-5)


Phase 2 — File / type coverage

PR 5 — PD / MG / PLS / BT structure files (#5)

Scope: Add PD (PID), MG (Message), PLS (Programmable Limit Switch), BT (Block Transfer) file types to the parser and the data-type catalogue. PD has SP/PV/CV/Error/Bias plus 25+ sub-elements; MG has Error/Length/Position/etc.; PLS has LEN/POS; BT is similar to MG.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs — extend IsKnownFileLetter with PD, MG, PLS, BT.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDataType.cs — new enum members PidElement, MessageElement, PlsElement, BlockTransferElement. Sub-element catalogue per type — many PD sub-elements are Float32 (SP, PV, CV, KP, KI, KD), some are Boolean (EN, DN, MO, PE), some Int16 (SPS, MAXS, MINS).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs — verify libplctag PCCC supports addressing PD/MG/PLS/BT sub-elements by name; if not, the driver reads the parent struct as a byte block and offsets internally (libplctag docs to consult).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.csSupportsPidFile etc. flags (PLC-5 supports PD/BT; SLC supports PD; ML1100/1400 generally do not — verify per family docs).

Test plan:

  • Unit: PD9:0.SP → Float32; PD9:0.EN → Boolean; MG10:0.LEN → Int32; reject PD9:0 (no sub-element on a struct file).
  • Integration: smoke test against a simulator with PD file configured (verify pylogix/pycomm3 sim supports PD, otherwise mark as TODO and lean on unit coverage).

Docs / fixture / e2e:

  • New doc docs/drivers/AbLegacy-Structure-Files.md — sub-element catalogues for PD / MG / PLS / BT, per-family availability matrix (PLC-5 vs SLC vs ML), DriverDataType per sub-element.
  • Update docs/Driver.AbLegacy.Cli.md — add PD / MG / PLS / BT rows to the file-letter primer with --type PidElement etc. examples.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — list new structure-file file letters under unit coverage and note any fixture limitations (pd/mg likely not supported by ab_server).
  • Fixture: extend tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml slc500 and plc5 profiles with --tag=PD9[2] / --tag=MG10[2] if ab_server accepts; otherwise document gap in Docker/README.md and rely on unit coverage.
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with a read --type Float -a PD9:0.SP assertion when fixture exposes the file; add a corresponding tag row to scripts/smoke/seed-ablegacy-smoke.sql (skip-gated).

Effort: M Dependencies: PR 3 (sub-element bit semantics machinery must exist first — PD .EN is Boolean by the same mechanism as Timer .EN)


PR 6 — ST string read/write production verification (#10)

Scope: ST is enum-listed and LibplctagLegacyTagRuntime.DecodeValue calls _tag.GetString(0), but there's no integration coverage that ST round-trips through libplctag's 82-byte length-word format. This PR is verification + any fixes uncovered.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs — likely no source change if libplctag's GetString/SetString already handles the length-word convention; if not, add GetByteArrayBuffer + manual length-word decode.
  • tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs — add ST_RoundTrip_* tests against the simulator: write 82-char string, write 0-char, write 41-char, write embedded null/non-ASCII; round-trip each through ReadAsync.
  • New tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyStringEncodingTests.cs — unit-level decode of a known length-word + payload byte buffer (mock IAbLegacyTagRuntime returning fixed bytes).

Test plan:

  • Integration: 4 round-trip cases above; covers PlcFamily=Slc500 and PlcFamily=Plc5 (libplctag may handle the length word differently between the two PCCC layers — verify).
  • Quality: unit test that BadOutOfRange surfaces when caller writes a 100-char string to an 82-byte ST.

Docs / fixture / e2e:

  • Update docs/Driver.AbLegacy.Cli.md — expand the ST row in the address primer with the 82-byte limit, length-word convention, and a write --type String --value "Hello" worked example.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — list the new AbLegacyStringEncodingTests unit class and the four ST_RoundTrip_* integration cases under coverage.
  • Fixture: extend tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml slc500 and plc5 profiles with --tag=ST20[5] so the round-trip tests have a real address to write against; document any ab_server ST gaps in Docker/README.md.
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with a String round-trip case (-a "ST20:0" --type String) and a String tag row in scripts/smoke/seed-ablegacy-smoke.sql so the bridge assertion exercises ST.

Effort: S (mostly tests; small encoding fix if any) Dependencies: none


Phase 3 — Performance

PR 7 — Array contiguous block addressing (N7:0,10 or N7:0[10]) (#9)

Scope: One PCCC frame can pull up to ~120 words. Today every tag is a separate libplctag instance and a separate request. The fix exposes array tags as a single tag with IsArray=true + ArrayDim, backed by a libplctag tag with elem_count=N.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs — record gains ArrayCount (nullable). Parser accepts ,N suffix (Rockwell convention) and [N] suffix (libplctag convention) on the word number. Reject combination with sub-element or bit index.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.csAbLegacyTagDefinition gains optional ArrayLength (overrides parsed value; convenient when address is parameterised).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.csAbLegacyTagCreateParams gains ElementCount (default 1).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs — pass ElementCount to libplctag Tag.ElementCount (verify libplctag supports element counts on PCCC PlcTypes — it does for ab_eip CIP tags but PCCC may behave differently).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.csDiscoverAsync emits IsArray=true, ArrayDim=[N]; ReadAsync decodes via per-index _tag.GetInt16(i*2) etc.

Test plan:

  • Unit: N7:0,10 parses ArrayCount=10; N7:0[10] same; N7:0,10/3 rejects (array+bit); T4:0,5.ACC rejects (array+sub-element).
  • Integration: read N7:0,10 returns 10 elements in one frame; latency measurement vs 10 individual tags should be ≥ 5x faster (target).

Docs / fixture / e2e:

  • Update docs/Driver.AbLegacy.Cli.md — add an "Array reads" section explaining N7:0,10 vs N7:0[10] syntax and the per-PCCC-frame ~120-word ceiling, plus a read --array-length 10 -a N7:0,10 CLI example.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — list array-block reads under unit coverage and note the latency benchmark integration test as a new perf-flagged case.
  • Fixture: confirm tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml --tag=N7[10] / --tag=F8[10] already provide enough contiguous words; otherwise bump array sizes (N7[120] to allow max-frame tests).
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with a read -a "N7:0,10" array assertion (parse comma-separated CLI output); add a matching IsArray=1 tag row in scripts/smoke/seed-ablegacy-smoke.sql to exercise the address-space side.

Effort: M Dependencies: PR 1 (octal applies to array index when the file is I/O on PLC-5)


PR 8 — Per-tag deadband / change filter (#18)

Scope: Today PollGroupEngine publishes every poll. Add absolute and percent deadband per tag — only emit OnDataChange when the new value differs by ≥ deadband.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.csAbLegacyTagDefinition gains AbsoluteDeadband (double?), PercentDeadband (double?).
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs — wrap the PollGroupEngine callback with a per-tag last-published-value cache and the deadband test. Booleans bypass deadband (always change-on-edge). Strings + status changes always publish.
  • Verify: PollGroupEngine (in Core.Drivers) doesn't already centralise this — if it does, this PR threads the per-tag config through the engine instead of layering on top.

Test plan:

  • Unit (new AbLegacyDeadbandTests): tag with AbsoluteDeadband=1.0 reading [10.0, 10.5, 11.5, 11.6] publishes only 10.0 and 11.5. Boolean tag publishes every transition. Status code change always publishes.
  • Quality: ensure last-value cache doesn't leak across ReinitializeAsync.

Docs / fixture / e2e:

  • Update docs/Driver.AbLegacy.Cli.md — add a "Deadband" subsection under subscribe with --deadband-absolute / --deadband-percent CLI flags and example.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — list AbLegacyDeadbandTests under unit coverage.
  • Fixture: no compose change required (per-tag deadband is a config-side concern, not a server simulator one).
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with a deadband subscribe assertion (subscribe with --deadband-absolute 5, write three small deltas, assert only one notification fires); add a tag row to scripts/smoke/seed-ablegacy-smoke.sql with AbsoluteDeadband=5 to exercise the seed-from-config path.

Effort: S Dependencies: none


Phase 4 — Workflow

PR 9 — Per-device timeout / retry overrides (#21)

Scope: Replace single driver-wide Timeout with per-device override (SLC 5/01 needs ~5 s, SLC 5/05 fine at 2 s, ML1100 sometimes 3 s). Optional retry count per device.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.csAbLegacyDeviceOptions gains optional Timeout, Retries. AbLegacyDriverOptions.Timeout becomes the driver-wide default.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.csEnsureTagRuntimeAsync and ProbeLoopAsync use device.Options.Timeout ?? _options.Timeout. ReadAsync retry loop honours device.Options.Retries.

Test plan:

  • Unit: device with Timeout=TimeSpan.FromSeconds(5) propagates into AbLegacyTagCreateParams.Timeout; absent override falls back to driver-wide.
  • Integration: simulate a slow device (1 s artificial delay) — driver-wide 2 s passes; reducing per-device to 500 ms surfaces BadCommunicationError on the slow device while the fast device keeps reading.

Docs / fixture / e2e:

  • Update docs/Driver.AbLegacy.Cli.md — document per-device --timeout-ms / --retries precedence vs driver-wide defaults; add a tuning cheat-sheet for SLC 5/01 vs 5/05 vs ML1100.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — note per-device options under the AbLegacyDeviceOptions surface.
  • Fixture: no compose change. Add a slow-device test harness using a tc qdisc add dev eth0 delay 1000ms sidecar (or a Linux iptables -j DELAY shim) — document in Docker/README.md as an optional perf-tuning fixture.
  • E2E: no test-ablegacy.ps1 change needed (per-device timeout is integration-test territory). Add a Timeout=PT500MS device-level row to scripts/smoke/seed-ablegacy-smoke.sql so the seed path exercises the new column.

Effort: S Dependencies: none


PR 10 — Diagnostic counters as tags (#20)

Scope: Per-device diagnostic counters (request count, response count, retry count, last-error code, comm-failures) surface as auto-generated tags under AbLegacy/<host>/_Diagnostics/* so HMIs can bind directly. Mirrors what other drivers expose.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.csDeviceState gains Counters (record of int64s). ReadAsync, WriteAsync, ProbeLoopAsync increment counters on success/failure paths. DiscoverAsync emits a _Diagnostics folder per device with seven Variables: RequestCount, ResponseCount, ErrorCount, RetryCount, LastErrorCode, LastErrorMessage, CommFailures.
  • New src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs — generates the 7 well-known tag names; reading them returns counter snapshots from DeviceState.Counters.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs ReadAsync short-circuits diagnostic tag references before dispatching to libplctag.

Test plan:

  • Unit (new AbLegacyDiagnosticsTests): force 5 reads (3 success, 2 fail) → RequestCount=5, ErrorCount=2. LastErrorCode reflects the last libplctag status. Counters reset on ReinitializeAsync.
  • Quality: verify the 7 well-known names don't collide with user-config tag names (reject overlap at InitializeAsync).

Docs / fixture / e2e:

  • New doc docs/drivers/AbLegacy-Diagnostics.md — the seven well-known counter tag names, their semantics, namespace convention (_Diagnostics folder per device), reset behaviour on ReinitializeAsync, and HMI binding examples.
  • Update docs/Driver.AbLegacy.Cli.md — note that diagnostic tags surface alongside user-config tags and can be read --address _Diagnostics/RequestCount (or whatever the canonical CLI shape ends up being).
  • Update docs/drivers/AbLegacy-Test-Fixture.md — list AbLegacyDiagnosticsTests and call out the collision-rejection contract.
  • Fixture: no compose change.
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with a "after N reads, RequestCount==N" assertion against the diagnostic NodeId published by the OPC UA server-bridge step; add a _Diagnostics/RequestCount Tag row to scripts/smoke/seed-ablegacy-smoke.sql if the addr-space team requires explicit registration.

Effort: M Dependencies: none


PR 11 — RSLogix 500 / PLC-5 symbol & data-table import (#15)

Scope: Import RSLogix exports (.RSS Slc500, .RSP Plc5, .SLC text export) to seed AbLegacyTagDefinition entries. The binary .RSS/.RSP formats are proprietary and largely undocumented; the practical strategy is to support the .SLC / .CSV text exports that RSLogix can produce ("save as text" / "Database Export"). Verify whether libplctag or a sister project ships an .RSS parser — if not, scope to text exports only and document the binary case as a future enhancement.

Files:

  • New src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Import/RsLogixSymbolImport.cs — parses RSLogix text export (CSV: Symbol,Address,Description,DataType,Scope).
  • New src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Import/IRsLogixImporter.cs — abstraction for future binary support.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs — extension method AddRsLogixImport(string path, string deviceHostAddress) materialises AbLegacyTagDefinition entries from the file at startup-time.
  • New CLI command in src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ (mirrors AbCip CLI patterns — verify: confirm the AbLegacy CLI project layout): import-rslogix --file foo.csv --device ab://... --emit appsettings-fragment.

Test plan:

  • Unit (new RsLogixSymbolImportTests): canonical CSV with one of each file letter (N/F/B/L/ST/T/C/R) generates 8 AbLegacyTagDefinition entries with correct DataType. Malformed rows skipped with logged warning. Comments and header rows skipped.
  • Integration: an end-to-end test with a recorded RSLogix CSV (committed under tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/) produces an addr-space matching a golden snapshot.

Docs / fixture / e2e:

  • New doc docs/drivers/AbLegacy-RSLogix-Import.md — supported export formats (CSV / .SLC text), CSV column convention, scope handling, the import-rslogix CLI subcommand, and the explicit non-goal of binary .RSS/.RSP parsing for v1.
  • Update docs/Driver.AbLegacy.Cli.md — add an import-rslogix subcommand row to the commands table with --file foo.csv --device ab://... --emit appsettings-fragment example.
  • Update docs/DriverClis.md if it carries a per-CLI command matrix.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — list RsLogixSymbolImportTests, the new tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/ golden CSV, and the import-then-read integration scenario.
  • Fixture: new committed CSV under tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/rslogix-canonical.csv plus the corresponding golden snapshot. No Docker/docker-compose.yml change.
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with an import-rslogix invocation that emits an appsettings fragment, then asserts the resulting tag count matches the CSV row count. No seed-ablegacy-smoke.sql change (importer is offline tooling).

Effort: L (parser + CLI + golden-snapshot fixture) Dependencies: PR 15 complete (importer must produce addresses the parser accepts)


Phase 5 — Resilience

PR 12 — Auto-demote on comm failure (#13)

Scope: When a device fails N consecutive reads/probes, mark it Demoted and skip its tags for DemoteFor seconds — so one slow PLC doesn't starve fast PLCs sharing the same driver/poll cadence.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs — new AbLegacyDemoteOptions { FailureThreshold=3, DemoteFor=TimeSpan.FromSeconds(30), Enabled=true } on AbLegacyDeviceOptions.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.csDeviceState gains ConsecutiveFailures, DemotedUntilUtc. ReadAsync short-circuits demoted devices with BadCommunicationError until DemotedUntilUtc. ProbeLoopAsync clears demote on first success. New HostState.Demoted enum value (verify HostState is in Core.Abstractions and adding a member is non-breaking).
  • Diagnostic tags from PR 10 gain DemoteCount and LastDemotedUtc.

Test plan:

  • Unit (new AbLegacyAutoDemoteTests): force 3 consecutive failures → device transitions to Demoted; reads while demoted return BadCommunicationError without invoking libplctag (verify via test fake counting ReadAsync calls). After DemoteFor expires, the next read attempt goes through.
  • Integration: two devices on the same driver, one with a fault — fault doesn't slow down the healthy one.

Docs / fixture / e2e:

  • New doc docs/drivers/AbLegacy-AutoDemote.md (or a section appended to AbLegacy-Diagnostics.md from PR 10) — failure-threshold + demote-window semantics, interaction with the probe loop, the HostState.Demoted enum value, recovery path.
  • Update docs/Driver.AbLegacy.Cli.md — add --demote-failure-threshold / --demote-for per-device flags and document how probe reflects the Demoted state.
  • Update docs/drivers/AbLegacy-Test-Fixture.md — list AbLegacyAutoDemoteTests and the two-device fault-isolation integration case.
  • Fixture: extend tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml with a second slc500-faulty service that listens on :44819 but rejects every read (or doesn't bind, simulating ECONNREFUSED). The driver test then targets both :44818 (healthy) and :44819 (faulty) to exercise demotion.
  • E2E: extend scripts/e2e/test-ablegacy.ps1 with a "kill simulator, observe demotion in _Diagnostics/DemoteCount" assertion (gated on PR 10's diagnostic tags being present). Add a DemoteFor=PT30S device row to scripts/smoke/seed-ablegacy-smoke.sql.

Effort: M Dependencies: PR 10 (diagnostic counters)


PR 13 — DH+ via 1756-DHRIO bridging (#2)

Scope: Allow addressing a PLC-5 sitting on a DH+ link reached through a ControlLogix chassis with a 1756-DHRIO module. The CIP path syntax is 1,<slot>,2,<dh+_station_octal> — already accepted as a string by AbLegacyHostAddress, but we should validate and document it, and verify libplctag's plc5 PlcType resolves DH+ stations correctly through the DHRIO port.

Files:

  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyHostAddress.cs — add validation for the DH+ path form 1,<slot>,2,<station> where station is 0..77 octal. Surface the parsed components (BackplaneSlot, DhPlusPort, DhPlusStation) for diagnostics.
  • src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs — note that DH+ bridging is a Plc5-only path (DHRIO doesn't bridge to SLC/ML).
  • docs/Driver.AbLegacy.Cli.md — add a worked example of DHRIO routing.

Test plan:

  • Unit (AbLegacyHostAndStatusTests): ab://10.0.0.1/1,3,2,07 parses with slot=3, station=7₈=7. ab://10.0.0.1/1,3,2,77 parses station=77₈=63. ab://10.0.0.1/1,3,2,80 rejects (octal range).
  • Integration: requires a real DHRIO + PLC-5 — flag as hardware-gated; cover with unit-only for now and document the manual smoke procedure (docs/Driver.AbLegacy.Cli.md).

Docs / fixture / e2e:

  • New doc docs/drivers/AbLegacy-DH-Bridging.md — the 1,<slot>,2,<station_octal> CIP path syntax, DHRIO module wiring overview, octal-station-number reference (00..77 octal = 0..63), restriction to PLC-5 family, and the manual smoke procedure since DHRIO can't be simulated.
  • Update docs/Driver.AbLegacy.Cli.md — extend the family/cip-path cheat sheet with a "PLC-5 via DHRIO" row showing ab://logix-host/1,3,2,07 and a worked CLI example. (Plan already calls this out at line 279 — keep it, but link to the new dedicated doc.)
  • Update docs/drivers/AbLegacy-Test-Fixture.md — note that DH+ bridging is unit-only (no fixture support possible) and reference the manual hardware smoke procedure.
  • Fixture: no Docker/docker-compose.yml change is feasible (DHRIO is hardware-only).
  • E2E: no new automated test-ablegacy.ps1 case (would require real DHRIO). Add a -DhPlusStation 7 parameter form documented in the script comment header for hardware-gated runs only. No seed-ablegacy-smoke.sql change.

Effort: S Dependencies: PR 1 (octal parsing utility) — share the octal-int helper between PR 1 and PR 13.


Documentation, fixture, and e2e impact

Consolidated view of every doc, fixture, and e2e/smoke artefact this plan touches, so reviewers and PR authors can size the non-code surface area at a glance.

New docs (created by this plan)

Doc Created by Purpose
docs/drivers/AbLegacy-MicroLogix-FunctionFiles.md PR 2 Function-file catalogue (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI), per-family availability, sub-element types
docs/drivers/AbLegacy-Indirect-Addressing.md PR 4 N7:[N7:0] and N[N7:0]:5 syntax, depth-1 limit, libplctag strategy
docs/drivers/AbLegacy-Structure-Files.md PR 5 PD / MG / PLS / BT sub-element catalogues + per-family availability matrix
docs/drivers/AbLegacy-Diagnostics.md PR 10 Seven well-known counter tag names, namespace convention, reset semantics
docs/drivers/AbLegacy-RSLogix-Import.md PR 11 CSV / .SLC text-export schema, import-rslogix CLI, binary-format non-goals
docs/drivers/AbLegacy-AutoDemote.md (or PR 10 doc extension) PR 12 Demote thresholds, recovery, HostState.Demoted semantics
docs/drivers/AbLegacy-DH-Bridging.md PR 13 1,<slot>,2,<station_octal> CIP path, DHRIO wiring, manual smoke procedure

Updated docs (extended by this plan)

  • docs/Driver.AbLegacy.Cli.md — extended by every PR (octal I/O, function files, sub-element bits, indirect, structure files, ST round-trip, array reads, deadband flags, per-device timeouts, diagnostic tags, RSLogix import subcommand, demote flags, DHRIO cheat-sheet row).
  • docs/drivers/AbLegacy-Test-Fixture.md — extended by every PR with new unit test classes, integration cases, and fixture limitations.
  • docs/DriverClis.md — touched by PR 11 (new import-rslogix subcommand row).
  • tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md — touched by PRs 1, 2, 4, 5, 9, 12 (fixture limitations, optional perf-tuning sidecars, faulty-device service, recipe-pattern note).

Fixture / scaffolding work

  • tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml:
    • PR 1: extend plc5 profile with I:001-style tags (if ab_server accepts).
    • PR 2: extend micrologix profile with RTC0[1]/HSC0[1] (if accepted).
    • PR 3: extend slc500 profile with T4[5]/C5[5]/R6[5] if not already seeded by ab_server defaults.
    • PR 5: extend slc500 and plc5 profiles with PD9[2]/MG10[2] (if accepted).
    • PR 6: extend slc500 and plc5 profiles with ST20[5].
    • PR 7: bump array sizes (N7[120]) for max-frame array-read tests.
    • PR 12: add a second slc500-faulty service for demotion/fault-isolation tests.
  • tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/:
    • PR 11: new rslogix-canonical.csv + golden snapshot for the symbol-import integration test.

E2E / smoke scripts

  • scripts/e2e/test-ablegacy.ps1:
    • PR 1: octal-bit Plc5 assertion.
    • PR 2: MicroLogix RTC:0.HR parametric.
    • PR 3: Boolean sub-element read (T4:0.DN).
    • PR 4: indirect-address loopback.
    • PR 5: PD9:0.SP Float read (skip-gated).
    • PR 6: ST round-trip.
    • PR 7: array-read N7:0,10.
    • PR 8: deadband subscribe assertion.
    • PR 10: _Diagnostics/RequestCount assertion via OPC UA bridge.
    • PR 11: import-rslogix invocation + tag-count assertion.
    • PR 12: kill-simulator-and-observe-demote assertion.
    • PR 13: parameter-only header note for hardware-gated DHRIO runs.
  • scripts/smoke/seed-ablegacy-smoke.sql:
    • PR 3: T4:0.DN Boolean tag row.
    • PR 5: PD9:0.SP PidElement tag row (skip-gated).
    • PR 6: ST20:0 String tag row.
    • PR 7: N7:0,10 array tag row (IsArray=1).
    • PR 8: tag row with AbsoluteDeadband=5.
    • PR 9: device row with Timeout=PT500MS.
    • PR 10: _Diagnostics/RequestCount tag row (if explicit registration required).
    • PR 12: device row with DemoteFor=PT30S.

Skip-rated items (for context)

For traceability, the gaps the recommendations table flagged No:

# Gap Skip rationale
1 Serial DF1 transports (full-duplex, half-duplex, KF2/KF3) libplctag has no serial path; declining install base
3 DH-485 routing (1761/1747-AIC) Very legacy; rare in greenfield
4 M0 / M1 module file access Niche RIO modules; declining
6 D (BCD) and Long-BCD types Very legacy data convention
12 Block read-size negotiation per family libplctag handles chunking implicitly
14 Channel-shared comm serialisation Only matters for serial / DH+ transport (not built)
16 Online controller browse / data-table discovery PCCC dir frame limited; libplctag support unclear
17 DF1 BCC vs CRC-16 selection Predicated on DF1 transport (gap #1)
19 PLC-5 typed-read selection / Force Logical libplctag defaults are sound; niche tuning
22 Write completion semantics options Niche tuning; current write-through is safe default

These remain documented in featuregaps.md and can be reopened if customer feedback warrants.


Open questions

  1. libplctag PCCC capability verification — several PRs (especially 2, 4, 5, 7) hinge on what libplctag's slc500 / micrologix / plc5 / logixpccc PlcTypes actually accept in the Name attribute. Before scheduling Phase 2 we should run a one-day spike with the AbLegacy simulator to confirm:
    • Does libplctag accept indirect addresses (N7:[N7:0]) verbatim, or do we need to resolve in two steps?
    • Does it accept array notation (N7:0,10 vs N7:0[10]) for PCCC PlcTypes?
    • Does it expose PD/MG/PLS/BT sub-elements by name, or do we read the parent struct as a byte block?
    • Does it correctly handle PLC-5 octal in I:/O: addresses, or does the driver need to convert?
  2. MicroLogix simulator fidelity — we don't currently know whether the AbLegacy integration-test fixture (AbLegacyServerFixture) simulates the MicroLogix function files (RTC/HSC/DLS). PR 2's integration coverage is gated on this. If not, we either extend the fixture or scope PR 2 to unit-only tests + a hardware smoke-test playbook.
  3. RSLogix import format coverage — binary .RSS / .RSP parsing is non-trivial. PR 11 scopes to text/CSV exports. Should we instead invest in shelling out to the (free) Rockwell RSWho / RSLogix Emulate tooling for binary conversion, or accept text-only as the v1 scope and revisit?
  4. Address-space rebuild on tag-set change — when PR 11 (RSLogix import) adds 1000+ tags, does ReinitializeAsync perform acceptably, or do we need an incremental discovery path? Out of scope for this plan but worth flagging.
  5. Diagnostic tag namespace collision — PR 10 reserves _Diagnostics under each device folder. Confirm with the address-space team that the leading underscore is the established convention (other drivers use _System or _DiagnosticTags); align before implementation.