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>
This commit is contained in:
682
docs/plans/abcip-plan.md
Normal file
682
docs/plans/abcip-plan.md
Normal file
@@ -0,0 +1,682 @@
|
||||
# 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)?
|
||||
470
docs/plans/ablegacy-plan.md
Normal file
470
docs/plans/ablegacy-plan.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# AbLegacy Driver — Implementation Plan
|
||||
|
||||
> Source of gap analysis: [featuregaps.md → AbLegacy](../featuregaps.md#ablegacy-allen-bradley-plc-5--slc--micrologix)
|
||||
>
|
||||
> 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.cs` — `EnsureTagRuntimeAsync` 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.cs` — `SupportsFunctionFiles` 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.cs` — `DiscoverAsync` 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.cs` — `SupportsPidFile` 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.cs` — `AbLegacyTagDefinition` gains optional `ArrayLength` (overrides parsed value; convenient when address is parameterised).
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs` — `AbLegacyTagCreateParams` 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.cs` — `DiscoverAsync` 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.cs` — `AbLegacyTagDefinition` 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.cs` — `AbLegacyDeviceOptions` gains optional `Timeout`, `Retries`. `AbLegacyDriverOptions.Timeout` becomes the driver-wide default.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — `EnsureTagRuntimeAsync` 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.cs` — `DeviceState` 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 1–5 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.cs` — `DeviceState` 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.
|
||||
807
docs/plans/focas-plan.md
Normal file
807
docs/plans/focas-plan.md
Normal file
@@ -0,0 +1,807 @@
|
||||
# FOCAS Driver — Implementation Plan
|
||||
|
||||
> Source of gap analysis: [featuregaps.md → FOCAS](../featuregaps.md#focas-fanuc-cnc)
|
||||
>
|
||||
> Covers Build = Yes items only.
|
||||
|
||||
## Summary
|
||||
|
||||
The FOCAS driver today is a pure-managed, read-only FOCAS/2 wire client
|
||||
(`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/`) backing a fixed-tree projection
|
||||
plus user-authored `PARAM:` / `MACRO:` / PMC tags. It exposes a thin set of
|
||||
calls (`cnc_sysinfo`, `cnc_rdcncstat`, `cnc_rdaxisname`, `cnc_rdspdlname`,
|
||||
`cnc_rddynamic2`, `cnc_rdsvmeter`, `cnc_rdspload`, `cnc_rdspmaxrpm`,
|
||||
`cnc_exeprgname2`, `cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdtimer`,
|
||||
`cnc_rdparam`, `cnc_rdmacro`, `pmc_rdpmcrng`, `cnc_rdalmmsg2`).
|
||||
|
||||
The featuregaps table marks **18** items as Build = Yes. They cluster into
|
||||
five distinct workstreams:
|
||||
|
||||
1. **Phase 1 — fixed-tree expansion** (#6, #7, #8, #10, #11, #12, #13, #14,
|
||||
#18, #20, #24, #27). These are mostly new wire calls plumbed into the
|
||||
existing `FixedTree*` poll cadences; no architectural change.
|
||||
2. **Phase 2 — addressing additions** (#4, #14 DIAG scheme, #15, #16). New
|
||||
`FocasAreaKind` values, new capability-matrix entries, multi-path
|
||||
`PathId`. Touches the parser + matrix + wire envelope; mostly additive.
|
||||
3. **Phase 3 — alarm history** (#17). Extends the existing
|
||||
`FocasAlarmProjection` with a one-shot history pull on connect plus
|
||||
periodic delta polls.
|
||||
4. **Phase 4 — write path** (#1, #3). The biggest behavioural change in
|
||||
the driver's lifetime: removes the `BadNotWritable` short-circuit, adds
|
||||
`cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` plus FOCAS password
|
||||
handling. Material risk surface — see Risks.
|
||||
5. **Phase 5 — derived telemetry** (#24 cycle-delta computation). Optional
|
||||
companion to #24 raw cycle time; computes "last completed cycle" from
|
||||
the existing cumulative `Cycle` timer.
|
||||
|
||||
DIAG (#14) is in Phase 2 (addressing) rather than Phase 1 because it
|
||||
needs a new address scheme, but the fixed-tree status flag projection
|
||||
(#12) is the cheapest item and should land first as a vertical slice.
|
||||
|
||||
The remaining 9 items in the featuregaps table (HSSB, Series 15 / 35i,
|
||||
tool-offset write, program upload/download, DPRNT, deep servo info,
|
||||
acceleration/jerk, operator preset commands, NTP) are scoped out as
|
||||
Build = No; they appear in [Skip-rated items](#skip-rated-items-for-context)
|
||||
for context only.
|
||||
|
||||
## Phased delivery
|
||||
|
||||
| Phase | Scope | Gaps closed | Approx PRs | Risk |
|
||||
|-------|-------|-------------|------------|------|
|
||||
| 1 | Fixed-tree expansion (read-only) | 12, 13, 7, 8, 10, 11, 20, 18, 6, 24, 27, 14 (read-only piece) | 6 | Low |
|
||||
| 2 | Addressing additions | 4, 15, 16, 14 (DIAG: scheme) | 4 | Medium (multi-path) |
|
||||
| 3 | Alarm history | 17 | 1 | Low |
|
||||
| 4 | Write path + password | 1, 3 | 4 | High (read-only design choice removed) |
|
||||
| 5 | Cycle-delta derived telemetry | 24 (delta companion) | 1 | Low |
|
||||
|
||||
Phases 1–3 are mutually independent and can ship in any order. Phase 4
|
||||
deliberately follows Phase 2 so writes ride on top of the multi-path
|
||||
addressing already in place. Phase 5 tags onto the cycle-time node from
|
||||
Phase 1.
|
||||
|
||||
## Per-PR detail
|
||||
|
||||
### Phase 1 — fixed-tree expansion
|
||||
|
||||
Common shape: each PR adds one or more wire calls in
|
||||
`Wire/FocasWireClient.cs`, surfaces them on `IFocasClient`, plumbs them
|
||||
into `FocasDriver`'s `FixedTreeLoopAsync` cadences (axis 250 ms / program
|
||||
1 s / timer 30 s) and the `TryReadFixedTree` synthesizer, then adds
|
||||
fakes + assertions.
|
||||
|
||||
**PR F1-a — ODBST status flags as fixed-tree nodes (#12)**
|
||||
- Scope: project the 9 fields of `cnc_rdcncstat` (`tmmode`, `aut`, `run`,
|
||||
`motion`, `mstb`, `emergency`, `alarm`, `edit`, `dummy`) under
|
||||
`Status/` per device. We already issue this call in `ProbeAsync`; this
|
||||
PR keeps the boolean probe but additionally caches the full struct on
|
||||
every poll tick.
|
||||
- Files:
|
||||
`Wire/FocasWireClient.cs` (extend `ReadStatusAsync` to return the
|
||||
whole `WireStatus` rather than only `IsOk`), `IFocasClient.cs` (new
|
||||
`GetStatusAsync`), `FocasDriver.cs` (new `Status/*` branch in
|
||||
`TryReadFixedTree`, status cache on `DeviceState`).
|
||||
- Tests:
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeStatusTests.cs`
|
||||
(new) — `FakeFocasClient` returns canned ODBST, assert each field maps
|
||||
to the expected `Status/*` browse name. Integration: extend
|
||||
`FocasSimFixture` to seed the simulator's status response and assert
|
||||
via the OPC UA client.
|
||||
- **Docs / fixture / e2e**: extend `docs/drivers/FOCAS.md` fixed-tree
|
||||
table with the 9 `Status/*` nodes; mention the boolean-probe →
|
||||
full-struct change in `docs/drivers/FOCAS-Test-Fixture.md` integration
|
||||
bullet list; teach `focas-mock` (under
|
||||
`tests/.../IntegrationTests/Docker/focas-mock/`) the `cnc_rdcncstat`
|
||||
payload shape per `docs/v2/implementation/focas-wire-protocol.md`
|
||||
(add ODBST struct entry); extend `FocasSimFixture` with a helper to
|
||||
patch the canned status payload; new
|
||||
`Series/StatusFlagsPopulateTests.cs` integration test.
|
||||
- Effort: small; one wire call already exists.
|
||||
- Risk: Low.
|
||||
|
||||
**PR F1-b — parts count + cycle time (#13, #24 raw)**
|
||||
- Scope: surface `cnc_rdparam(6711)` (parts produced), `6712` (parts
|
||||
required), `6713` (parts total since power-on) under `Production/`,
|
||||
plus `Production/CycleTimeSeconds` (already exposed as
|
||||
`Timers/CycleSeconds` — promote to the `Production/` group too with
|
||||
the same backing). The existing `cnc_rdtimer` call is sufficient.
|
||||
- Files: `FocasDriver.cs` (`Production/*` branch, parameter-cached
|
||||
reads on the timer poll cadence), `IFocasClient.cs` (no new call —
|
||||
rides on `ReadParameterInt32Async`).
|
||||
- Tests: `FakeFocasClient` returns canned parameter values; assert
|
||||
`Production/PartsTotal` equals the canned value.
|
||||
- **Docs / fixture / e2e**: add `Production/*` rows to the fixed-tree
|
||||
table in `docs/drivers/FOCAS.md`; add `Production:` example to
|
||||
`docs/Driver.FOCAS.Cli.md` (a `read -a PARAM:6711` snippet); the
|
||||
parts-count parameters (6711/6712/6713) are already in the
|
||||
simulator profile range, so only the `dl205`-style profile JSON
|
||||
under `tests/.../Docker/focas-mock/profiles/` needs seeded values
|
||||
added; extend `FocasSimFixture` with a `SeedPartsCount` helper;
|
||||
integration test under `Series/ProductionPopulatesTests.cs`.
|
||||
- Effort: small.
|
||||
- Risk: Low.
|
||||
|
||||
**PR F1-c — modal G/M/T codes (#7) + override values (#11)**
|
||||
- Scope: add `cnc_modal` (command id TBD per `fwlib32.h` — the wire
|
||||
protocol uses the same numeric command convention seen in
|
||||
`FocasWireClient`; capture during simulator iteration). Project:
|
||||
`Modal/G_Group{n}` (groups 1..21), `Modal/MCode`, `Modal/SCode`,
|
||||
`Modal/TCode`, `Modal/BCode`. Adds `Override/Feed`, `Override/Rapid`,
|
||||
`Override/Spindle`, `Override/Jog` from `cnc_rdparam(...)` — the
|
||||
override percent registers live at known parameter numbers; numbers
|
||||
are MTB-specific so pull defaults from
|
||||
`docs/v2/focas-version-matrix.md` and let operators override per device.
|
||||
- Files: `Wire/FocasWireClient.cs` (new `ReadModalAsync`), new
|
||||
`Wire/FocasWireModels.cs` records `WireModal` / `WireModalGroup`,
|
||||
`IFocasClient.cs` (new `GetModalAsync`), `FocasDriver.cs` (new
|
||||
poll-medium branches under the program-poll cadence).
|
||||
- Tests: `FocasModalTests.cs` (unit), simulator handler returns canned
|
||||
modal payload, integration asserts `Modal/G_Group1` text.
|
||||
- **Docs / fixture / e2e**: add `Modal/*` and `Override/*` sections to
|
||||
the fixed-tree table in `docs/drivers/FOCAS.md`, including the
|
||||
G-group decode table for groups 01/03/06/07/14; add a `MODAL:`
|
||||
address example row to `docs/Driver.FOCAS.Cli.md` (new `read -a
|
||||
MODAL:G1` style — note: this PR does NOT add a new address scheme,
|
||||
the modal data is fixed-tree only, so the CLI example reads via
|
||||
`read -n "ns=2;s=Modal/G_Group1"` over the OPC UA endpoint);
|
||||
document MTB-specific override register defaults in
|
||||
`docs/v2/focas-version-matrix.md` (new `Override registers per
|
||||
series` table); capture the `cnc_modal` command id resolved during
|
||||
simulator iteration into `docs/v2/implementation/focas-wire-protocol.md`
|
||||
(new struct entry — promote out of the open-questions list);
|
||||
update `docs/v2/implementation/focas-simulator-plan.md` Stream C
|
||||
protocol-surface table with the new `cnc_modal` handler;
|
||||
extend focas-mock with a `cnc_modal` command-id handler + canned
|
||||
modal payload per profile; integration test reading G54/G90 modal
|
||||
state via `Series/ModalPopulatesTests.cs`.
|
||||
- Effort: medium — `cnc_modal` returns a multi-group struct; encoding
|
||||
needs care.
|
||||
- Risk: Medium — modal-group numbering varies by series; treat the
|
||||
raw integer as the value the CNC reports and surface a string
|
||||
decode table only for the universally-present groups (G-group 01
|
||||
motion, 03 absolute/incremental, 06 input units, 07 cutter comp,
|
||||
14 work coordinate). Document MTB-specific groups as raw int.
|
||||
|
||||
**PR F1-d — tool number / tool life (#8) + work coordinate offsets (#10)**
|
||||
- Scope: add `cnc_rdtofs` / `cnc_rdtlife*` / `cnc_rdzofs`. Project
|
||||
`Tooling/CurrentTool`, `Tooling/CurrentOffset`,
|
||||
`Tooling/Life/{group}/Remaining`, `Tooling/Life/{group}/Total`,
|
||||
`Offsets/G54..G59[+ extended]/{X,Y,Z}`.
|
||||
- Files: new wire calls in `Wire/FocasWireClient.cs` (`ReadToolOffsetAsync`,
|
||||
`ReadToolLifeAsync`, `ReadWorkOffsetAsync`), `Wire/FocasWireModels.cs`
|
||||
(records), `IFocasClient.cs`, `FocasDriver.cs` (new `Tooling/` and
|
||||
`Offsets/` branches; both poll on the slow timer cadence — these
|
||||
change at setup time, not per-cycle), capability matrix per-call
|
||||
suppression like the existing `Spindle/` gating.
|
||||
- Tests: unit + simulator. Tool-life is the largest payload; assert
|
||||
array projection rather than per-tool nodes (one ValueRank=1 array
|
||||
per group keeps the address-space size bounded on machines with
|
||||
500+ tool slots).
|
||||
- **Docs / fixture / e2e**: add `Tooling/*` and `Offsets/*` sections to
|
||||
the fixed-tree table in `docs/drivers/FOCAS.md`, including the
|
||||
ValueRank=1 array note for tool-life groups; add a per-series
|
||||
capability-suppression row to `docs/v2/focas-version-matrix.md`
|
||||
(which series support `cnc_rdtlife*` vs not); document the three
|
||||
new structs (`ODBTOFS`, `ODBTLIFE5`, `IODBZOR`) in
|
||||
`docs/v2/implementation/focas-wire-protocol.md`; add
|
||||
`cnc_rdtofs` / `cnc_rdtlife*` / `cnc_rdzofs` rows to the protocol
|
||||
surface table in `docs/v2/implementation/focas-simulator-plan.md`;
|
||||
extend focas-mock with three new command-id handlers + per-profile
|
||||
seed data (tool table + work-offset table); add a
|
||||
`tools_per_series` matrix to the `focas-mock` per-series profile
|
||||
JSON so 0i-D's small tool table differs from 30i's; new
|
||||
`Series/ToolingPopulatesTests.cs` and `Series/OffsetsPopulatesTests.cs`
|
||||
integration tests; update `docs/drivers/FOCAS-Test-Fixture.md`
|
||||
coverage map with the three new wire calls.
|
||||
- Effort: large — three new calls, each with its own struct; tool-life
|
||||
is variable-length.
|
||||
- Risk: Medium — payload shapes are series-specific; keep the
|
||||
capability matrix as the authoritative gate.
|
||||
|
||||
**PR F1-e — operator messages (#18) + currently-executing block text (#20)**
|
||||
- Scope: `cnc_rdopmsg3` (gives all four FANUC opmsg classes in one
|
||||
call), `cnc_rdactpt` (current block text). Project `Messages/External`
|
||||
(variable, last-N strings), `Program/CurrentBlock` (single string).
|
||||
- Files: `Wire/FocasWireClient.cs` (`ReadOperatorMessagesAsync`,
|
||||
`ReadCurrentBlockAsync`), `IFocasClient.cs`, `FocasDriver.cs` (new
|
||||
branches under program-poll cadence).
|
||||
- Tests: simulator returns canned ASCII; assert string round-trip is
|
||||
trim-stable (FANUC right-pads with `\0` or space).
|
||||
- **Docs / fixture / e2e**: add `Messages/External` and
|
||||
`Program/CurrentBlock` rows to the fixed-tree table in
|
||||
`docs/drivers/FOCAS.md`, including the ring-buffer / last-N
|
||||
semantics for opmsg; document the `OPMSG3` and `ODBACT2`
|
||||
payload shapes in `docs/v2/implementation/focas-wire-protocol.md`;
|
||||
add `cnc_rdopmsg3` / `cnc_rdactpt` rows to the protocol surface
|
||||
table in `docs/v2/implementation/focas-simulator-plan.md`; extend
|
||||
focas-mock with the two new command-id handlers (per-profile
|
||||
canned message text + canned current-block text); add a
|
||||
`mock_patch_opmsg` admin endpoint hook on `FocasSimFixture` for
|
||||
tests that need to push a canned message; integration test
|
||||
`Series/OperatorMessagesPopulateTests.cs` asserts trim-stable
|
||||
round-trip and last-N retention.
|
||||
- Effort: medium.
|
||||
- Risk: Low — ASCII-only payloads.
|
||||
|
||||
**PR F1-f — `cnc_getfigure` decimal scaling (#6) + connection statistics (#27)**
|
||||
- Scope: `cnc_getfigure` returns per-axis decimal-place counts; cache
|
||||
the result at bootstrap and divide each `AbsolutePosition` /
|
||||
`MachinePosition` / `RelativePosition` / `DistanceToGo` /
|
||||
`ActualFeedRate` value before publishing. Existing nodes already
|
||||
carry `Float64`; the change is invisible to clients except that
|
||||
values become real-world units. Adds `Diagnostics/` subtree:
|
||||
`Diagnostics/ReadCount`, `Diagnostics/ReadFailureCount`,
|
||||
`Diagnostics/LastErrorMessage`, `Diagnostics/LastSuccessfulRead`,
|
||||
`Diagnostics/ReconnectCount` — driven by counters already maintained
|
||||
on `DeviceState`.
|
||||
- Files: `Wire/FocasWireClient.cs` (new `ReadFigureAsync`),
|
||||
`IFocasClient.cs`, `FocasDriver.cs` (cache decimal places per axis,
|
||||
multiply on the read path, expose counters under `Diagnostics/`).
|
||||
- Tests: assert that with a canned `cnc_getfigure` returning 3, an
|
||||
`AbsolutePosition` of 12345 becomes `12.345`. Connection-stat tests
|
||||
assert counters increment under known conditions.
|
||||
- **Docs / fixture / e2e**: significant `docs/drivers/FOCAS.md` change —
|
||||
add a "Decimal-place scaling" subsection explaining the
|
||||
`FixedTree.ApplyFigureScaling` flag (default true on new installs,
|
||||
false on migrations) and the unit-correctness semantics it enforces;
|
||||
add `Diagnostics/*` rows to the fixed-tree table; add a
|
||||
Diagnostics-counters subsection to `docs/v2/focas-deployment.md`
|
||||
for operator dashboards; document `cnc_getfigure` (`ODBAXDP` /
|
||||
`ODBAXIS`) struct in `docs/v2/implementation/focas-wire-protocol.md`;
|
||||
add `cnc_getfigure` to the protocol surface in
|
||||
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
|
||||
with the per-axis decimal-place command handler + a `decimal_places`
|
||||
field on each profile JSON; update
|
||||
`docs/drivers/FOCAS-Test-Fixture.md` "When to trust each layer"
|
||||
table with a "Are axis values reported in real-world units?" row;
|
||||
add an opt-in `-CheckDecimalScaling` switch to `scripts/e2e/test-focas.ps1`
|
||||
that asserts AbsolutePosition is scaled when the flag is on;
|
||||
integration test `Series/DecimalScalingTests.cs` and
|
||||
`Series/DiagnosticsCountersTests.cs`.
|
||||
- Effort: medium — touches every axis read.
|
||||
- Risk: Medium — this is a behavioural change for any existing
|
||||
consumer that was already dividing client-side. Surface as a
|
||||
`FixedTree.ApplyFigureScaling` opt-in flag (default true on new
|
||||
installs, false when migrating); document in `docs/drivers/FOCAS.md`.
|
||||
|
||||
### Phase 2 — addressing additions
|
||||
|
||||
**PR F2-a — DIAG: address scheme (#14)**
|
||||
- Scope: new `FocasAreaKind.Diagnostic` parsed from `DIAG:nnn` /
|
||||
`DIAG:nnn/axis`, dispatched to `cnc_rddiag` (or `cnc_rddiagdgn` for
|
||||
series that support it).
|
||||
- Files: `FocasAddress.cs` (new prefix branch), `FocasCapabilityMatrix.cs`
|
||||
(new `DiagnosticRange` per series), `Wire/FocasWireClient.cs`
|
||||
(`ReadDiagnosticAsync`), `WireFocasClient.ReadAsync` (new dispatch
|
||||
branch).
|
||||
- Tests: parser unit tests, capability matrix unit tests, simulator
|
||||
read-round-trip.
|
||||
- **Docs / fixture / e2e**: add a `DIAG:` row to the address-syntax
|
||||
table in `docs/Driver.FOCAS.Cli.md` with `read -a DIAG:301` and
|
||||
`DIAG:301/0` (axis-scoped) examples; add a `DIAG:` row to the
|
||||
addressing table in `docs/drivers/FOCAS.md`; add per-series
|
||||
`DiagnosticRange` columns to `docs/v2/focas-version-matrix.md`;
|
||||
document the `ODBDGN` struct in
|
||||
`docs/v2/implementation/focas-wire-protocol.md`; add `cnc_rddiag`
|
||||
/ `cnc_rddiagdgn` to the protocol surface in
|
||||
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
|
||||
with the diagnostic-range command handler + per-profile seeded
|
||||
diagnostic numbers; integration test
|
||||
`Series/DiagAddressTests.cs` round-trips a seeded diagnostic
|
||||
number; update `docs/drivers/FOCAS-Test-Fixture.md` capability list
|
||||
with the new `Diagnostic` `FocasAreaKind`.
|
||||
- Effort: medium.
|
||||
- Risk: Low — additive.
|
||||
|
||||
**PR F2-b — Multi-path / multi-channel CNC (#4)**
|
||||
- Scope: 30i/31i/32i can host 2–10 paths; today every request block is
|
||||
built with `PathId = 1` (`Wire/FocasWireProtocol.cs:216`). Add
|
||||
optional `Path` segment to `FocasAddress` (e.g. `PARAM:1815@2`,
|
||||
`R100@3.0`, `MACRO:500@2`); thread it into the `RequestBlock.PathId`
|
||||
field. Fixed-tree gets a `Paths/{n}/` folder pivot.
|
||||
- Files: `FocasAddress.cs` (new `Path` field + parser), `IFocasClient.cs`
|
||||
(every read call gains an optional `pathId` parameter, defaulting to
|
||||
1 for backward compatibility), `Wire/FocasWireClient.cs`
|
||||
(thread the param through every `RequestBlock` constructor),
|
||||
`FocasDriver.cs` (per-device `PathCount` discovery via
|
||||
`cnc_rdpathnum`; iterate fixed-tree per path).
|
||||
- Tests: unit on the parser; simulator with two paths configured;
|
||||
assert that a `PARAM:1815@2` read targets path 2.
|
||||
- **Docs / fixture / e2e**: significant `docs/drivers/FOCAS.md`
|
||||
update — new "Multi-path / multi-channel CNC" subsection explaining
|
||||
the `@N` suffix syntax, `Paths/{n}/` browse pivot, and per-path
|
||||
capability gating; add `@N` to every address row in the
|
||||
addressing table in `docs/Driver.FOCAS.Cli.md`; document
|
||||
`cnc_rdpathnum` (`ODBPATHNUM` struct) in
|
||||
`docs/v2/implementation/focas-wire-protocol.md`, and update the
|
||||
`RequestBlock.PathId` discussion (was hard-coded to 1 — now a
|
||||
parameter); add `cnc_rdpathnum` to the protocol surface and the
|
||||
per-profile `path_count` field to the profile schema in
|
||||
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
|
||||
with per-path state isolation (separate PMC / param / macro tables
|
||||
per `path_id`) and a new `multi_path` profile (e.g.
|
||||
`thirtyone_i_dual_path`); add a `-Paths` switch to
|
||||
`scripts/e2e/test-focas.ps1` that runs the matrix once per
|
||||
declared path; document the new compose profile in
|
||||
`docs/drivers/FOCAS-Test-Fixture.md`; new
|
||||
`Series/MultiPathTests.cs` integration test asserting independent
|
||||
per-path reads.
|
||||
- Effort: large — touches every wire call's `RequestBlock` shape.
|
||||
- Risk: Medium — backward compatibility for existing single-path
|
||||
configs. Default `PathId = 1` everywhere; only deviate when the
|
||||
address explicitly carries a `@N` suffix or when the fixed-tree
|
||||
loop is iterating discovered paths.
|
||||
|
||||
**PR F2-c — PMC F/G letters for 16i (#15)**
|
||||
- Scope: capability matrix bug — `PmcLetters(Sixteen_i)` currently
|
||||
returns `{X, Y, R, D}`; real 16i ladders use F/G for handshakes.
|
||||
Widen the set; verify the address `pmc_rdpmcrng` numeric letter
|
||||
codes match.
|
||||
- Files: `FocasCapabilityMatrix.cs` (one-line fix to the 16i case),
|
||||
`tests/.../FocasCapabilityMatrixTests.cs` (assert F0.0 and G50.5
|
||||
parse against `Sixteen_i`).
|
||||
- **Docs / fixture / e2e**: update the 16i row of the PMC-letters
|
||||
column in `docs/v2/focas-version-matrix.md` (the row currently lists
|
||||
X/Y/R/D — add F/G); add a one-line "fixed in v…" callout to the
|
||||
changelog section of the same doc; no simulator change required (the
|
||||
16i profile JSON in `tests/.../Docker/focas-mock/profiles/sixteen_i.json`
|
||||
already has F/G ranges declared from Stream B); add F0.0 / G50.5
|
||||
probes to the 16i row of the per-series matrix in
|
||||
`scripts/e2e/test-focas.ps1`; no fixture-doc change needed.
|
||||
- Effort: trivial.
|
||||
- Risk: Low — correctness fix.
|
||||
|
||||
**PR F2-d — Bulk PMC range read (#16)**
|
||||
- Scope: today the driver issues one `pmc_rdpmcrng` per tag (one TCP
|
||||
RTT each). The wire call already supports a range `[start, end]`;
|
||||
the missing piece is coalescing on the read side. Add a coalescer:
|
||||
group same-letter contiguous (or near-contiguous within a small
|
||||
gap budget) PMC bytes from the request batch into one wire call
|
||||
per group, then slice client-side. Reuse the Modbus coalescing
|
||||
infrastructure pattern (per-group-id ProhibitedRanges) where it
|
||||
applies.
|
||||
- Files: new `Wire/FocasPmcCoalescer.cs`, hook into
|
||||
`FocasDriver.ReadAsync` between the per-tag path and the wire call
|
||||
layer. Surface coalesce stats on the `Diagnostics/` subtree (PR F1-f).
|
||||
- Tests: unit — given a request batch of `R100..R110`, assert that
|
||||
the coalescer issues one call covering 100..110 and slices the
|
||||
result. Integration — assert observed wire-call count drops with
|
||||
coalescing on.
|
||||
- **Docs / fixture / e2e**: add a "PMC range coalescing" subsection
|
||||
to `docs/drivers/FOCAS.md` (wire-call reduction, gap budget,
|
||||
per-series byte cap); document the new `Diagnostics/CoalesceStats/*`
|
||||
counters added on top of PR F1-f's diagnostics tree; add a
|
||||
PMC-byte-cap column to `docs/v2/focas-version-matrix.md`;
|
||||
no new wire calls (`pmc_rdpmcrng` is already in the surface), but
|
||||
document the supported max-bytes-per-call in
|
||||
`docs/v2/implementation/focas-wire-protocol.md`; extend focas-mock
|
||||
with a request-counter admin endpoint so integration tests can
|
||||
assert the call-count reduction (counter visible via
|
||||
`FocasSimFixture.GetWireCallCountAsync`); update
|
||||
`docs/v2/implementation/focas-simulator-plan.md` Stream B
|
||||
validation harness with the request-counter handler; integration
|
||||
test `Series/PmcCoalescingTests.cs` asserts an `R100..R110` batch
|
||||
produces exactly 1 wire call against the mock.
|
||||
- Effort: medium.
|
||||
- Risk: Medium — the FANUC max-bytes-per-`pmc_rdpmcrng` ceiling is
|
||||
series-specific; cap conservatively (≤ 256 bytes per range) and
|
||||
let operators raise it via config if their CNC accepts more.
|
||||
|
||||
### Phase 3 — alarm history
|
||||
|
||||
**PR F3-a — `cnc_rdalmhistry` extension to alarm projection (#17)**
|
||||
- Scope: extend `FocasAlarmProjection` with two modes — `ActiveOnly`
|
||||
(today's behaviour) and `ActivePlusHistory`. In the latter, on
|
||||
connect (and on a configurable cadence — default 5 min, since the
|
||||
CNC ring buffer changes only on alarm raise/clear) issue
|
||||
`cnc_rdalmhistry` for the most-recent N entries; project as
|
||||
historic events through `IAlarmSource` with `OccurrenceTime` from
|
||||
the CNC's timestamp field.
|
||||
- Files: new `Wire/FocasWireClient.ReadAlarmHistoryAsync`, new
|
||||
`IFocasClient.ReadAlarmHistoryAsync`,
|
||||
`FocasAlarmProjection.cs` (mode switch + history poll loop),
|
||||
`FocasDriverOptions.cs` (`AlarmProjection.Mode` enum +
|
||||
`HistoryPollInterval` + `HistoryDepth`).
|
||||
- Tests: simulator returns canned history payload; assert events
|
||||
fire with the timestamps from the canned data and don't re-fire
|
||||
on every poll.
|
||||
- **Docs / fixture / e2e**: add an "Alarm history" subsection to
|
||||
`docs/drivers/FOCAS.md` documenting the `ActiveOnly` vs
|
||||
`ActivePlusHistory` mode switch, the `HistoryDepth` cap, and the
|
||||
dedup key; add a configuration-knob row to
|
||||
`docs/v2/focas-deployment.md` for operator dashboards; document
|
||||
`ODBALMHIS` struct in
|
||||
`docs/v2/implementation/focas-wire-protocol.md`; add
|
||||
`cnc_rdalmhistry` to the protocol surface in
|
||||
`docs/v2/implementation/focas-simulator-plan.md`; extend focas-mock
|
||||
with a ring-buffer alarm history (per profile) + `mock_patch_alarmhistory`
|
||||
admin endpoint; expose a `SeedAlarmHistoryAsync` helper on
|
||||
`FocasSimFixture`; add `Series/AlarmHistoryProjectionTests.cs`
|
||||
asserting historic events fire once and active events still fire
|
||||
raise/clear; update `docs/drivers/FOCAS-Test-Fixture.md` integration
|
||||
bullet list with `cnc_rdalmhistry`.
|
||||
- Effort: medium.
|
||||
- Risk: Medium — duplicate-event suppression; key history events on
|
||||
`(timestamp, alarmNumber, type)` to deduplicate against the active
|
||||
list.
|
||||
|
||||
### Phase 4 — write path
|
||||
|
||||
This phase is the major behavioural change. The driver's read-only
|
||||
contract has been the documented design choice in
|
||||
`docs/drivers/FOCAS.md:14-18` and is reinforced by tests
|
||||
(`FocasReadWriteTests.WriteAsync_ReturnsBadNotWritable`). Removing it
|
||||
deserves a deliberate decision-record entry in the v2 decisions log
|
||||
before any code lands.
|
||||
|
||||
**PR F4-a — write infrastructure + per-tag opt-in (no wire calls yet)**
|
||||
- Scope: drop the `BadNotWritable` short-circuit in
|
||||
`WireFocasClient.WriteAsync` and replace with a kind-based dispatch
|
||||
that returns `BadNotWritable` only for kinds the wire client
|
||||
doesn't yet implement. Honour `FocasTagDefinition.Writable` (already
|
||||
present, default `true` — flip default to `false` per #1's safer
|
||||
posture). Plumb `WriteIdempotent` through Polly retry.
|
||||
- Files: `WireFocasClient.cs`, `FocasDriverOptions.cs`,
|
||||
`FocasDriver.cs`, `docs/drivers/FOCAS.md` (rewrite the read-only
|
||||
paragraph), new `docs/v2/decisions.md` entry.
|
||||
- Tests: assert that with `Writable=false` the path still returns
|
||||
`BadNotWritable`; with `Writable=true` and an unimplemented kind
|
||||
the write returns `BadNotSupported` (distinct from the per-tag
|
||||
policy denial).
|
||||
- **Docs / fixture / e2e**: this is the heaviest doc PR in the plan.
|
||||
- **`docs/drivers/FOCAS.md` lines 14–18** — revoke the unconditional
|
||||
"OtOpcUa is read-only against FOCAS… Writes return BadNotWritable
|
||||
by design" callout. Replace with a "Writes (opt-in, off by
|
||||
default)" subsection that names `Writes.Enabled`, the per-tag
|
||||
`Writable` flag (default flipped to `false`), and links to the
|
||||
Phase 4 decision-record entry.
|
||||
- **`docs/drivers/FOCAS-Test-Fixture.md` lines 42–43** — revoke the
|
||||
"`IWritable` intentionally returns `BadNotWritable` — OtOpcUa is
|
||||
read-only against FOCAS" callout. Replace with a qualified
|
||||
"default behaviour" note plus a pointer to the new write-enabled
|
||||
test profile.
|
||||
- **`docs/Driver.FOCAS.Cli.md` lines 100–116** — the existing
|
||||
`write` section already documents the CLI shape; expand the
|
||||
"**Writes are non-idempotent by default**" warning with a
|
||||
server-side note that the OtOpcUa endpoint enforces the
|
||||
`Writes.Enabled` flag and rejects writes when off, and that
|
||||
the CLI itself talks to the driver directly so its writes are
|
||||
not gated by the server flag (operator must consciously use
|
||||
the right tool).
|
||||
- New `docs/v2/decisions.md` entry "FOCAS write-path opt-in"
|
||||
capturing the design-choice reversal.
|
||||
- Update `docs/featuregaps.md` row for #1 / #3 — flip Build = Yes
|
||||
annotation to "shipping behind flag".
|
||||
- Simulator: no new commands; existing read commands gain a
|
||||
"writes when not unlocked" branch wired up here for symmetry
|
||||
even though no write commands ship yet (returns
|
||||
`BadNotSupported` until F4-b lands).
|
||||
- E2E: add `-Write` switch (no-op stage in this PR; populated by
|
||||
F4-b) to `scripts/e2e/test-focas.ps1`.
|
||||
- Effort: medium.
|
||||
- Risk: High — design-choice reversal. Mitigation: ship behind a
|
||||
driver-level `Writes.Enabled` flag (default `false`); operators
|
||||
must explicitly enable in `appsettings.json`.
|
||||
|
||||
**PR F4-b — `cnc_wrmacro` + `cnc_wrparam`**
|
||||
- Scope: implement macro and parameter writes. Both have well-defined
|
||||
payload shapes mirroring their read counterparts (IODBPSD for
|
||||
parameters, ODBM for macros).
|
||||
- Files: `Wire/FocasWireClient.cs` (new `WriteParameterAsync`,
|
||||
`WriteMacroAsync`), `WireFocasClient.WriteAsync` (dispatch).
|
||||
- Tests: simulator extension — accept writes and reflect them on
|
||||
subsequent reads. ACL tests in
|
||||
`tests/ZB.MOM.WW.OtOpcUa.IntegrationTests` to verify the
|
||||
server-layer enforcement (per the memory entry: ACL decisions
|
||||
happen in `DriverNodeManager`, never in driver-level code).
|
||||
- **Docs / fixture / e2e**:
|
||||
- `docs/drivers/FOCAS.md` — extend the "Writes" subsection
|
||||
(introduced in F4-a) with the two new write kinds, the
|
||||
`Writes.AllowParameter` and `Writes.AllowMacro` granular flags,
|
||||
and a security note: parameter writes require LDAP group
|
||||
`WriteConfigure`, macro writes require `WriteOperate` (cross-link
|
||||
to `docs/Security.md`).
|
||||
- `docs/v2/focas-deployment.md` — significant addition: a "Write
|
||||
safety" section covering operator pre-checks (CNC in MDI mode,
|
||||
parameter-write switch enabled), audit-log expectations, and the
|
||||
LDAP group requirements.
|
||||
- `docs/Driver.FOCAS.Cli.md` — populate the existing `write`
|
||||
examples for `PARAM:` and `MACRO:` (already present at lines
|
||||
105–108) with a "Server-enforced ACL" note linking to
|
||||
`docs/Security.md`.
|
||||
- Document `IODBPSD` (write side) and `ODBM` (write side) in
|
||||
`docs/v2/implementation/focas-wire-protocol.md` (the read-side
|
||||
structs are already there — flag the byte layout symmetry).
|
||||
- `docs/v2/implementation/focas-simulator-plan.md` — add
|
||||
`cnc_wrparam` / `cnc_wrmacro` to the protocol surface table
|
||||
and update Stream C status accordingly.
|
||||
- Extend focas-mock with `cnc_wrparam` / `cnc_wrmacro` handlers
|
||||
that mutate the per-profile state and return
|
||||
`EW_PASSWD` when the unlock state is off (sets up F4-d's
|
||||
test path); add `mock_get_last_write` admin endpoint for
|
||||
audit-log assertions.
|
||||
- New `Series/ParameterWriteTests.cs` and `Series/MacroWriteTests.cs`
|
||||
integration tests; ACL test under
|
||||
`tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.cs`
|
||||
asserting `WriteConfigure` is required for `PARAM:` writes and
|
||||
`WriteOperate` for `MACRO:` writes.
|
||||
- `scripts/e2e/test-focas.ps1` — populate the `-Write` stage from
|
||||
F4-a with macro and parameter round-trip writes against the
|
||||
Docker mock.
|
||||
- Effort: medium.
|
||||
- Risk: High — a misdirected parameter write can put the CNC into a
|
||||
bad state. Surface a `Writes.AllowParameter` flag separate from
|
||||
`Writes.Enabled` so operators can grant macro writes without
|
||||
parameter writes.
|
||||
|
||||
**PR F4-c — `pmc_wrpmcrng`**
|
||||
- Scope: PMC range writes. Read-modify-write semantics for bit-level
|
||||
writes (the wire call is byte-addressed). Existing tests
|
||||
(`FocasPmcBitRmwTests.cs`) prove the read-modify-write pattern
|
||||
shape that the write path needs.
|
||||
- Files: `Wire/FocasWireClient.cs` (new `WritePmcRangeAsync`),
|
||||
bit-level RMW helper in `WireFocasClient`.
|
||||
- Tests: simulator round-trip on byte writes; bit-level write asserts
|
||||
the unrelated bits in the same byte are preserved.
|
||||
- **Docs / fixture / e2e**:
|
||||
- `docs/drivers/FOCAS.md` — extend the "Writes" subsection with
|
||||
PMC writes; loud safety callout block ("PMC is ladder working
|
||||
memory — a mistargeted bit can move motion"); document the
|
||||
read-modify-write semantics for bit-level writes; document the
|
||||
new `Writes.AllowPmc` granular flag.
|
||||
- `docs/v2/focas-deployment.md` — extend the "Write safety"
|
||||
section with PMC-specific pre-checks (e-stop, jog mode); add an
|
||||
ops-runbook bullet on auditing PMC writes from the
|
||||
`Diagnostics/CoalesceStats/` (extended) tree.
|
||||
- `docs/Driver.FOCAS.Cli.md` — the existing `write` example
|
||||
`write -h … -a G50.3 -t Bit -v on` (line 107) is already PMC-bit;
|
||||
update its surrounding warning to call out RMW behaviour.
|
||||
- Document the `pmc_wrpmcrng` request frame in
|
||||
`docs/v2/implementation/focas-wire-protocol.md` (the read frame
|
||||
is already there — flag the inverted shape).
|
||||
- `docs/v2/implementation/focas-simulator-plan.md` — add
|
||||
`pmc_wrpmcrng` to the protocol surface table.
|
||||
- Extend focas-mock with `pmc_wrpmcrng` handler that mutates
|
||||
per-profile PMC tables; assert byte-aligned writes preserve
|
||||
untouched bytes (mirrors the driver's RMW contract).
|
||||
- New `Series/PmcRangeWriteTests.cs` and
|
||||
`Series/PmcBitRmwIntegrationTests.cs` integration tests; ACL
|
||||
test under
|
||||
`tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasPmcWriteAclTests.cs`
|
||||
asserting `WriteOperate` is required.
|
||||
- `scripts/e2e/test-focas.ps1` — extend the `-Write` stage with a
|
||||
PMC bit round-trip.
|
||||
- Effort: medium.
|
||||
- Risk: High — PMC is the ladder logic's working memory; a
|
||||
mistargeted write can move motion. Document loudly.
|
||||
|
||||
**PR F4-d — FOCAS password / unlock parameter (#3)**
|
||||
- Scope: some controllers gate `cnc_wrparam` and certain reads behind
|
||||
a connection-level password. Add `Password` to `FocasDeviceOptions`;
|
||||
emit the FOCAS password block during connect (`cnc_wrunlockparam`
|
||||
per FOCAS docs — confirm the exact command id during simulator
|
||||
iteration). On any read/write returning `EW_PASSWD`
|
||||
re-issue the password and retry once.
|
||||
- Files: `Wire/FocasWireClient.cs` (`UnlockAsync`),
|
||||
`FocasDriverOptions.cs` (`Password` field, treated as a secret —
|
||||
redact in logs), `FocasDriver.cs` (call on connect).
|
||||
- Tests: simulator extension — emit `EW_PASSWD` on writes when not
|
||||
unlocked; assert the unlock+retry path.
|
||||
- **Docs / fixture / e2e**:
|
||||
- `docs/drivers/FOCAS.md` — new "FOCAS password" subsection under
|
||||
Writes describing the optional `Password` device-option, when
|
||||
the CNC requires it (16i + some 30i firmwares with parameter-
|
||||
protect on), and the redaction guarantee.
|
||||
- **Security-note in `docs/v2/focas-deployment.md`** — significant
|
||||
addition: a "FOCAS password handling" subsection covering
|
||||
storage in `appsettings.json` (and the dev redaction pattern at
|
||||
`.local/`), the no-log invariant, and a runbook for password
|
||||
rotation. Cross-link to `docs/Security.md`.
|
||||
- `docs/Driver.FOCAS.Cli.md` — add a `--cnc-password` flag row to
|
||||
the "Common flags" table with the redaction note.
|
||||
- Document `cnc_wrunlockparam` (or the resolved command id) in
|
||||
`docs/v2/implementation/focas-wire-protocol.md`; resolve the
|
||||
open question raised by F4-d into the doc.
|
||||
- `docs/v2/implementation/focas-simulator-plan.md` — add
|
||||
`cnc_wrunlockparam` to the protocol surface; document the
|
||||
per-profile `unlock_password` field on the JSON profile schema.
|
||||
- Extend focas-mock with locked-state semantics on parameter
|
||||
writes (already half-stubbed in F4-b's `EW_PASSWD` branch);
|
||||
add `cnc_wrunlockparam` handler; add `mock_set_password`
|
||||
admin endpoint so integration tests can pin the unlock value.
|
||||
- New `Series/PasswordUnlockTests.cs` integration test asserts
|
||||
a write returning `EW_PASSWD` triggers exactly one unlock
|
||||
retry, and the second write succeeds.
|
||||
- `scripts/e2e/test-focas.ps1` — add `-CncPassword` parameter,
|
||||
threaded through to the CLI for the `-Write` stage.
|
||||
- Effort: small — once Phase 4-a/b are in.
|
||||
- Risk: Medium — password storage. Use the existing
|
||||
`appsettings.json` redaction pattern (memory entry: `dohertj2`
|
||||
AppData path); never log the password value.
|
||||
|
||||
### Phase 5 — derived telemetry
|
||||
|
||||
**PR F5-a — Cycle time per part / last cycle delta (#24 derivation)**
|
||||
- Scope: with `Production/CycleTimeSeconds` in place from F1-b and
|
||||
the parts-count from `cnc_rdparam`, compute "last completed cycle"
|
||||
as the delta in `Timers/CycleSeconds` between successive
|
||||
parts-count increments. Project `Production/LastCycleSeconds`,
|
||||
`Production/LastCycleStartUtc`.
|
||||
- Files: `FocasDriver.cs` only — pure derivation in the program-poll
|
||||
cadence handler.
|
||||
- Tests: simulate a parts-count increment from 5→6; assert
|
||||
`LastCycleSeconds` equals the cycle-timer delta over the same
|
||||
window.
|
||||
- **Docs / fixture / e2e**: add `Production/LastCycleSeconds` and
|
||||
`Production/LastCycleStartUtc` rows to the fixed-tree table in
|
||||
`docs/drivers/FOCAS.md` with the rollover / counter-reset
|
||||
behaviour documented; add a `Derived telemetry` callout in
|
||||
`docs/v2/focas-deployment.md` explaining the derivation is
|
||||
client-visible only (no new wire calls); no
|
||||
`docs/v2/implementation/focas-wire-protocol.md` change (pure
|
||||
derivation); no focas-mock change beyond `FocasSimFixture`'s
|
||||
existing parameter-patch / timer-patch helpers — add a
|
||||
`SimulateCycleCompletionAsync` convenience helper that increments
|
||||
parts-count and advances the cycle timer atomically; new
|
||||
`Series/CycleDeltaTests.cs` integration test simulates a 5→6
|
||||
parts-count transition; no `scripts/e2e/test-focas.ps1` change.
|
||||
- Effort: small.
|
||||
- Risk: Low — pure derivation.
|
||||
|
||||
## Documentation, fixture, and e2e impact
|
||||
|
||||
Consolidated view of every doc, fixture, and e2e artefact this plan
|
||||
touches. FOCAS has the largest doc surface of any driver in the v2
|
||||
roadmap because Phase 4 reverses a long-standing read-only design
|
||||
choice that is referenced from at least three user-facing docs and one
|
||||
test-fixture doc.
|
||||
|
||||
### Docs touched (per file, with the heaviest PR called out)
|
||||
|
||||
| Doc | Touched by | Heaviest change |
|
||||
| --- | --- | --- |
|
||||
| `docs/drivers/FOCAS.md` | F1-a, F1-b, F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-a, F4-b, F4-c, F4-d, F5-a | **F4-a** revokes the read-only callout at lines 14–18; **F2-b** adds the multi-path subsection |
|
||||
| `docs/drivers/FOCAS-Test-Fixture.md` | F1-a, F1-d, F1-f, F2-a, F2-b, F3-a, F4-a | **F4-a** revokes the "`IWritable` intentionally returns `BadNotWritable`" callout at lines 42–43 |
|
||||
| `docs/Driver.FOCAS.Cli.md` | F1-b, F1-c, F2-a, F2-b, F4-a, F4-b, F4-c, F4-d | **F4-a** qualifies the read-only stance at lines 100–116; **F4-d** adds `--cnc-password` flag |
|
||||
| `docs/v2/focas-deployment.md` | F1-f, F3-a, F4-a, F4-b, F4-c, F4-d | **F4-b** adds "Write safety" section; **F4-d** adds "FOCAS password handling" section |
|
||||
| `docs/v2/focas-version-matrix.md` | F1-c, F1-d, F2-a, F2-c, F2-d | **F1-d** adds capability-suppression rows for tooling/offsets |
|
||||
| `docs/v2/implementation/focas-wire-protocol.md` | F1-a, F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-b, F4-c, F4-d | **F1-d** documents three new structs (ODBTOFS, ODBTLIFE5, IODBZOR); **F4-d** resolves the `cnc_wrunlockparam` open question |
|
||||
| `docs/v2/implementation/focas-simulator-plan.md` | F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-a, F4-b, F4-c, F4-d | Each PR appends to the protocol surface table; F4-* close out Stream C status |
|
||||
| `docs/v2/decisions.md` (new entry) | F4-a | Net-new decision-record for the read-only reversal |
|
||||
| `docs/featuregaps.md` | F4-a | Updates Build = Yes annotation for #1 / #3 with "shipping behind flag" |
|
||||
|
||||
### Fixture (focas-mock) extensions
|
||||
|
||||
The vendored Python `focas-mock` simulator under
|
||||
`tests/.../IntegrationTests/Docker/focas-mock/` gains the following
|
||||
new command-id handlers and per-profile state:
|
||||
|
||||
| PR | Mock extension |
|
||||
| --- | --- |
|
||||
| F1-a | `cnc_rdcncstat` full-struct response |
|
||||
| F1-b | Seeded values for parameters 6711/6712/6713 in every profile JSON |
|
||||
| F1-c | New `cnc_modal` handler + canned modal payload per profile |
|
||||
| F1-d | `cnc_rdtofs` / `cnc_rdtlife*` / `cnc_rdzofs` handlers + per-profile tool/offset tables, plus a `tools_per_series` profile knob |
|
||||
| F1-e | `cnc_rdopmsg3` / `cnc_rdactpt` handlers + `mock_patch_opmsg` admin endpoint |
|
||||
| F1-f | `cnc_getfigure` handler + per-profile `decimal_places` field |
|
||||
| F2-a | `cnc_rddiag` / `cnc_rddiagdgn` handlers + per-profile diagnostic numbers |
|
||||
| F2-b | Per-path state isolation; new `path_count` profile field; new `thirtyone_i_dual_path` compose profile |
|
||||
| F2-c | No mock change (16i profile already declares F/G ranges) |
|
||||
| F2-d | Wire-call counter admin endpoint |
|
||||
| F3-a | Ring-buffer alarm history + `mock_patch_alarmhistory` admin endpoint |
|
||||
| F4-a | Stub branch returning `BadNotSupported` for write commands |
|
||||
| F4-b | `cnc_wrparam` / `cnc_wrmacro` handlers (with `EW_PASSWD` when locked); `mock_get_last_write` admin endpoint |
|
||||
| F4-c | `pmc_wrpmcrng` handler with byte-aligned write semantics |
|
||||
| F4-d | `cnc_wrunlockparam` handler; `mock_set_password` admin endpoint; locked-state on the param-write path |
|
||||
| F5-a | `SimulateCycleCompletionAsync` helper on `FocasSimFixture` (no new mock command) |
|
||||
|
||||
`FocasSimFixture` (in
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs`)
|
||||
gains corresponding admin-API client helpers for each new endpoint.
|
||||
|
||||
### Integration tests (per phase)
|
||||
|
||||
| Phase | New / extended integration tests under `tests/.../FOCAS.IntegrationTests/Series/` |
|
||||
| --- | --- |
|
||||
| Phase 1 | `StatusFlagsPopulateTests.cs`, `ProductionPopulatesTests.cs`, `ModalPopulatesTests.cs`, `ToolingPopulatesTests.cs`, `OffsetsPopulatesTests.cs`, `OperatorMessagesPopulateTests.cs`, `DecimalScalingTests.cs`, `DiagnosticsCountersTests.cs` |
|
||||
| Phase 2 | `DiagAddressTests.cs`, `MultiPathTests.cs`, `PmcCoalescingTests.cs` (plus a 16i row in `FocasCapabilityMatrixTests.cs` for F2-c) |
|
||||
| Phase 3 | `AlarmHistoryProjectionTests.cs` |
|
||||
| Phase 4 | `ParameterWriteTests.cs`, `MacroWriteTests.cs`, `PmcRangeWriteTests.cs`, `PmcBitRmwIntegrationTests.cs`, `PasswordUnlockTests.cs` plus ACL tests under `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.cs` and `FocasPmcWriteAclTests.cs` |
|
||||
| Phase 5 | `CycleDeltaTests.cs` |
|
||||
|
||||
### E2E script (`scripts/e2e/test-focas.ps1`) updates
|
||||
|
||||
| PR | Change |
|
||||
| --- | --- |
|
||||
| F1-f | New `-CheckDecimalScaling` switch |
|
||||
| F2-b | New `-Paths` switch (matrix mode iterates per declared path) |
|
||||
| F2-c | Adds F0.0 / G50.5 probes to the 16i row of the per-series matrix |
|
||||
| F4-a | Adds `-Write` switch (no-op stage in F4-a; populated by F4-b/c) |
|
||||
| F4-b | Populates `-Write` stage with macro + parameter round-trip writes |
|
||||
| F4-c | Extends `-Write` stage with PMC bit round-trip |
|
||||
| F4-d | Adds `-CncPassword` parameter, threaded through to the CLI |
|
||||
|
||||
`scripts/integration/run-focas.ps1` does not change shape across the
|
||||
plan — it remains the compose up/test/compose down wrapper. New
|
||||
profiles registered by F2-b are automatically picked up via the
|
||||
existing `-Profile` switch.
|
||||
|
||||
### Read-only callouts requiring revocation in Phase 4
|
||||
|
||||
For reviewer benefit, the explicit read-only callouts that **F4-a
|
||||
must revoke or qualify** in the same PR that flips the design choice:
|
||||
|
||||
- `docs/drivers/FOCAS.md` lines 14–18 ("OtOpcUa is **read-only**
|
||||
against FOCAS… Writes return `BadNotWritable` by design.")
|
||||
- `docs/drivers/FOCAS-Test-Fixture.md` lines 42–43 ("`IWritable`
|
||||
intentionally returns `BadNotWritable` — OtOpcUa is read-only
|
||||
against FOCAS.")
|
||||
- `docs/Driver.FOCAS.Cli.md` lines 100–116 (write section is already
|
||||
documented but predates the server-side flag; needs a
|
||||
server-enforced-ACL note)
|
||||
- `docs/featuregaps.md` (FOCAS row entries for #1 and #3 carry the
|
||||
same read-only-by-design framing — flip annotation)
|
||||
|
||||
## Skip-rated items (for context)
|
||||
|
||||
These appear in the featuregaps recommendations table as Build = No;
|
||||
recapped here so reviewers can confirm the scope decision rather than
|
||||
re-deriving it from `featuregaps.md`:
|
||||
|
||||
- **#2 HSSB transport** — PCI hardware, declining install base,
|
||||
reopens the Fwlib distribution problem the wire client deliberately
|
||||
closed.
|
||||
- **#5 Series 15 / Power Mate D-H / Series 35i** — very legacy; small
|
||||
install base. Capability matrix already accepts `Unknown` as a
|
||||
permissive escape hatch.
|
||||
- **#9 Tool-offset write** — write-heavy; defer alongside the general
|
||||
write decision (F4 covers reads via tool-life only).
|
||||
- **#19 Program list / upload / download / delete** — DNC product
|
||||
territory; significant scope; out of OtOpcUa's MES focus.
|
||||
- **#21 DPRNT TCP listener** — significant scope; modern OPC UA
|
||||
alarms / events supersede it.
|
||||
- **#22 Servo / spindle deep info (`cnc_rdsvinfo` / `cnc_rdspinfo`)** —
|
||||
specialty; load-percent already covers most needs.
|
||||
- **#23 Per-axis acceleration / jerk / feed-per-rev** — niche
|
||||
advanced telemetry.
|
||||
- **#25 Operator write commands (preset, `cnc_setpath`, `cnc_wrabsmac`)** —
|
||||
read-only-by-design covers it; parameter / PMC / macro writes from
|
||||
Phase 4 are the supervisory writes operators actually need.
|
||||
- **#26 CNC time / date sync** — rare ask; commonly handled by CNC NTP.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Modal command id** (PR F1-c): `cnc_modal` numeric command code is
|
||||
not in the existing wire-protocol notes
|
||||
(`docs/v2/implementation/focas-wire-protocol.md`). Capture during
|
||||
the simulator iteration loop; if the simulator can't yet emit the
|
||||
shape, gate F1-c behind a bench-CNC trace per the
|
||||
diminishing-returns checkpoint.
|
||||
- **Override parameter numbers** (PR F1-c): feedrate / rapid /
|
||||
spindle override register numbers are MTB-specific. Default to the
|
||||
documented Fanuc factory numbers and let operators override per
|
||||
device (`Devices[].OverrideRegisters` map).
|
||||
- **Multi-path discovery** (PR F2-b): does the simulator support
|
||||
multi-path responses today? If not, F2-b lands gated behind the
|
||||
`OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1` flag the wire-protocol doc
|
||||
describes.
|
||||
- **Decimal-scaling migration** (PR F1-f): existing `Float64` axis
|
||||
nodes are scaled integers today. Decision: ship F1-f with
|
||||
scaling-on default, add a one-release deprecation window with the
|
||||
flag default-off so existing dashboards don't silently scale by
|
||||
10^N when the driver is upgraded. Need explicit operator opt-in.
|
||||
- **Write security posture** (Phase 4): should writes require LDAP
|
||||
group `WriteConfigure` (parameters) vs `WriteOperate` (macros /
|
||||
PMC)? Per the memory entry on ACL-at-server-layer, the driver only
|
||||
reports `SecurityClassification`; the server enforces. Need the
|
||||
driver to surface the right classification per address kind:
|
||||
`Configure` for `PARAM:`, `Operate` for `MACRO:` and PMC writes.
|
||||
- **Phase 4 rollout**: ship behind a feature flag in `appsettings.json`
|
||||
(`Drivers.{name}.Config.Writes.Enabled`) with `false` default for at
|
||||
least one release before flipping the default. Update
|
||||
`docs/drivers/FOCAS.md` and `docs/featuregaps.md` in the same PR
|
||||
that flips the default.
|
||||
- **Cycle-delta edge cases** (PR F5-a): parts-count rollover; counter
|
||||
reset by the operator. Default behaviour: emit the delta only when
|
||||
the counter strictly increments by 1; on any other transition emit
|
||||
`Production/LastCycleSeconds` as `null` with `BadOutOfRange` and
|
||||
let the operator interpret.
|
||||
863
docs/plans/opcuaclient-plan.md
Normal file
863
docs/plans/opcuaclient-plan.md
Normal file
@@ -0,0 +1,863 @@
|
||||
# OpcUaClient Driver — Implementation Plan
|
||||
|
||||
> Source of gap analysis: [featuregaps.md → OpcUaClient](../featuregaps.md#opcuaclient-opc-ua-aggregation-client)
|
||||
>
|
||||
> Covers Build = Yes items only. Numbering matches the featuregaps Recommendations table.
|
||||
|
||||
## Summary
|
||||
|
||||
The OpcUaClient driver already ships 8/8 capability interfaces and a working
|
||||
end-to-end Session/Subscription/MonitoredItem/HistoryRead pipeline backed by
|
||||
the OPC Foundation `OPCFoundation.NetStandard.Opc.Ua.Client` SDK. Most of the
|
||||
14 Build = Yes gaps are operability or curation knobs — config surface +
|
||||
plumbing into existing SDK calls — rather than new protocol implementation.
|
||||
A small number need genuinely new SDK plumbing (Reverse Connect,
|
||||
ModelChangeEvent subscribe) and one (`ReadEventsAsync`) needs a coordinated
|
||||
cross-driver interface change.
|
||||
|
||||
The plan groups the work into five phases, ordered to deliver per-tag /
|
||||
per-subscription operability first (highest-frequency operator pain), then
|
||||
curation, then change tracking, then connectivity, then historical+HA. Each
|
||||
PR sticks to one feature-gap row so reviews stay narrow.
|
||||
|
||||
## Phased delivery
|
||||
|
||||
| Phase | Theme | Gaps | PRs | Notes |
|
||||
| :---: | --- | --- | :---: | --- |
|
||||
| 1 | Operability knobs | #5, #6, #15, #17, #20 | 5 | Pure SDK config surface; no new wire flows |
|
||||
| 2 | Discovery & curation | #2, #7, #8, #9 | 4 | Touches `ITagDiscovery` + adds method invoke |
|
||||
| 3 | Change tracking | #10 | 1 | New session-level subscription on `Server` node |
|
||||
| 4 | Connectivity | #1 | 1 | Reverse Connect — new listener path |
|
||||
| 5 | Historical & redundancy | #12, #13, #14 | 3 | Includes the cross-driver `IHistoryProvider` change |
|
||||
|
||||
**Total: 14 PRs across 5 phases.** Phases 1-3 land independently against
|
||||
the existing single-session model. Phase 4 ships in parallel with phases 2-3
|
||||
since it doesn't touch `OpcUaClientDriver` proper. Phase 5's first PR is a
|
||||
prerequisite for the `ReadEventsAsync` work in every other history-capable
|
||||
driver and must coordinate with them.
|
||||
|
||||
## Per-PR detail
|
||||
|
||||
### Phase 1 — Operability knobs
|
||||
|
||||
#### PR-1: Per-subscription tuning (gap #6)
|
||||
|
||||
**Goal**: lift the hard-coded `KeepAliveCount=10`, `LifetimeCount=1000`,
|
||||
`MaxNotificationsPerPublish=0`, `Priority=0`, `PublishingInterval` floor of
|
||||
50 ms into `OpcUaClientDriverOptions` so high-event-rate servers can be
|
||||
defended against (`MaxNotificationsPerPublish=0` is unlimited — the
|
||||
documented DoS surface) and high-tag-count deployments can split by
|
||||
priority.
|
||||
|
||||
**SDK API**:
|
||||
- `Subscription.SetPublishingMode(bool, ct)` for runtime enable/disable
|
||||
- `SubscriptionOptions.PublishingInterval / KeepAliveCount / LifetimeCount /
|
||||
MaxNotificationsPerPublish / Priority` set at create-time
|
||||
- New options class `OpcUaSubscriptionDefaults` (publish interval floor,
|
||||
keep-alive count, lifetime count, max notifications, priority)
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add `Subscriptions`
|
||||
sub-section
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAsync` reads from
|
||||
options
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `SubscribeAlarmsAsync` reuses
|
||||
same defaults but with `Priority=1` higher than data subscriptions so
|
||||
alarms aren't starved during data bursts
|
||||
|
||||
**Tests**: `OpcUaClientSubscribeAndProbeTests` — assert options propagate;
|
||||
add a stress unit test (mocked `Subscription`) that asserts custom
|
||||
`MaxNotificationsPerPublish` is forwarded so a value > 0 actually reaches
|
||||
the SDK.
|
||||
|
||||
**Risks**: Setting `LifetimeCount` too low against a server with publish-
|
||||
throttling can drop subscriptions; doc the formula (`LifetimeCount >=
|
||||
3 * KeepAliveCount`).
|
||||
|
||||
**Docs / fixture / e2e**: new "Subscription tuning" subsection in
|
||||
`docs/drivers/OpcUaClient.md` (create if missing) documenting the
|
||||
`Subscriptions` options block with the `LifetimeCount >= 3 *
|
||||
KeepAliveCount` formula; cross-link from the "Advanced options" section
|
||||
of `docs/Client.CLI.md` so CLI users discover the knobs. Fixture: opc-plc
|
||||
already publishes fast tickers (`FastUInt1` @ 100 ms) sufficient for
|
||||
coverage — no fixture-side change. Integration test in
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserting
|
||||
custom `KeepAliveCount` / `Priority` reach the wire (capture via
|
||||
`OpcPlcFixture` keepalive count). E2E: extend
|
||||
`scripts/e2e/test-opcuaclient.ps1` with a stage that sets a non-default
|
||||
publish interval and confirms the local subscription honours it.
|
||||
|
||||
---
|
||||
|
||||
#### PR-2: Per-tag advanced subscription tuning incl. deadband (gap #5)
|
||||
|
||||
**Goal**: surface `SamplingInterval`, `QueueSize`, `DiscardOldest`,
|
||||
`MonitoringMode`, and `DataChangeFilter` (DeadbandType=Absolute/Percent +
|
||||
Trigger=Status/StatusValue/StatusValueTimestamp) per-tag. Deadband is the
|
||||
baseline analog noise filter every commercial UA aggregator ships and the
|
||||
single feature most likely to cut bandwidth on busy plants.
|
||||
|
||||
**SDK API**:
|
||||
- `MonitoredItem.Filter = new DataChangeFilter { Trigger =
|
||||
DataChangeTrigger.StatusValue, DeadbandType = (uint)DeadbandType.Absolute,
|
||||
DeadbandValue = 0.5 }`
|
||||
- `MonitoredItemOptions.QueueSize / DiscardOldest / SamplingInterval /
|
||||
MonitoringMode`
|
||||
- Per-tag override structure: extend the `SubscribeAsync` parameter shape
|
||||
(or add an overload accepting a `IReadOnlyList<MonitoredTagSpec>`) — note
|
||||
this requires coordinating with `ISubscribable` so the per-tag carrier
|
||||
reaches the driver.
|
||||
|
||||
**Files**:
|
||||
- `src/.../Core.Abstractions/ISubscribable.cs` — add overload
|
||||
`SubscribeAsync(IReadOnlyList<MonitoredTagSpec>, ...)` keeping old API
|
||||
for source compat
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — translate spec → SDK filter
|
||||
|
||||
**Tests**: assert `DataChangeFilter` lands on the `MonitoredItem.Filter` for
|
||||
each kind of trigger; assert PercentDeadband requires server-side
|
||||
EURange (server returns `BadFilterNotAllowed` if not configured) — capture
|
||||
the StatusCode and surface as a usable error.
|
||||
|
||||
**Risks**: cross-cutting `ISubscribable` change. Mitigation: ship the
|
||||
overload as additive — existing single-arg path still exists.
|
||||
|
||||
**Docs / fixture / e2e**: new "Per-tag deadband and monitoring filters"
|
||||
section in `docs/drivers/OpcUaClient.md` (create if missing) with worked
|
||||
examples of Absolute vs Percent deadband + the EURange prerequisite;
|
||||
update `docs/Client.CLI.md` `subscribe` command page with the new tag-
|
||||
config syntax for `--deadband` / `--queue-size` / `--discard-oldest`;
|
||||
update `docs/Client.UI.md` Subscriptions tab section to mirror. Fixture:
|
||||
`OpcPlcFixture` / `OpcPlcProfile` seeds an analog (`StepUp` already
|
||||
oscillates) and confirms `EURange` is published — extend the profile to
|
||||
flag noisy nodes. Integration test in
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` asserts
|
||||
publish suppression below the deadband threshold. E2E: add a
|
||||
`-DeadbandValue` stage to `scripts/e2e/test-opcuaclient.ps1` (and a
|
||||
`deadband` knob to `scripts/e2e/e2e-config.sample.json`) that subscribes,
|
||||
asserts no spurious updates within the band.
|
||||
|
||||
---
|
||||
|
||||
#### PR-3: Honor server `OperationLimits` (gap #15)
|
||||
|
||||
**Goal**: read `Server.ServerCapabilities.OperationLimits.MaxNodesPerRead /
|
||||
Write / Browse / HistoryReadData` once after Session activation, cache,
|
||||
and chunk batch operations to those caps client-side. Today the SDK chunks
|
||||
on its internal default; against an undersized embedded UA server this
|
||||
results in `BadTooManyOperations`.
|
||||
|
||||
**SDK API**:
|
||||
- After session open: `Session.ReadAsync` of
|
||||
`VariableIds.Server_ServerCapabilities_OperationLimits_MaxNodesPerRead`
|
||||
+ sibling NodeIds. The SDK exposes `Session.OperationLimits` after
|
||||
`FetchOperationLimits` is called — prefer that path.
|
||||
- `Session.FetchOperationLimitsAsync(ct)` (1.5+); fallback: explicit Read.
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — call
|
||||
`FetchOperationLimitsAsync` post-`OpenSessionOnEndpointAsync`; honour
|
||||
caps in `ReadAsync`, `WriteAsync`, `BrowseRecursiveAsync`,
|
||||
`EnrichAndRegisterVariablesAsync`, `ExecuteHistoryReadAsync`.
|
||||
|
||||
**Tests**: mock `Session.OperationLimits` to a value below the test batch
|
||||
size and assert the driver issues N wire calls instead of one.
|
||||
|
||||
**Risks**: a zero on the server means "no limit" per Part 5 — don't divide
|
||||
by zero.
|
||||
|
||||
**Docs / fixture / e2e**: new "Server OperationLimits handling"
|
||||
subsection in `docs/drivers/OpcUaClient.md` documenting the auto-fetch
|
||||
behaviour, the zero-means-unlimited semantics, and how to override via
|
||||
options if the server reports an under-truthful value. Fixture: opc-plc
|
||||
publishes the standard ServerCapabilities tree out of the box — no
|
||||
container-side change; the `OpcPlcFixture` seed validates the IDs at
|
||||
collection init. Integration test asserts batch reads chunk to the
|
||||
fetched cap. No e2e change needed (the script's batch sizes are already
|
||||
small).
|
||||
|
||||
---
|
||||
|
||||
#### PR-4: Diagnostics counters (gap #17)
|
||||
|
||||
**Goal**: expose per-driver counters on `DriverHealth` (or a sibling
|
||||
`DriverDiagnostics` surface): publish-request count, notifications-per-
|
||||
second EWMA, missing-publish-request count, dropped-notification rate,
|
||||
session resets count. Operators currently see only `LastSuccessfulRead`
|
||||
+ last error.
|
||||
|
||||
**SDK API**:
|
||||
- `Subscription.Notification` event fires per published notification — bump
|
||||
a counter
|
||||
- `Subscription.PublishStateChanged` event for missed-publish detection
|
||||
- `Session.PublishError` event for channel-level errors
|
||||
- `Session.SessionClosing`/`SessionConfigurationChanged` for session-reset
|
||||
attribution
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — instrument hooks; expose via
|
||||
`IDriver.GetDiagnostics()` or extend `DriverHealth`
|
||||
- `src/.../Core.Abstractions/IDriver.cs` — confirm where the counter shape
|
||||
lives; if `DriverHealth` is too rigid, add `IDriverDiagnostics` (mirrors
|
||||
the Modbus `driver-diagnostics` RPC pattern from #154)
|
||||
|
||||
**Tests**: synthetic notification fan-out → assert counters increment;
|
||||
session close → assert reset count bumps.
|
||||
|
||||
**Risks**: counters need to be lock-free hot-path safe; use
|
||||
`Interlocked.Increment` and a single sliding-window clock per counter.
|
||||
|
||||
**Docs / fixture / e2e**: new "Driver diagnostics" section in
|
||||
`docs/drivers/OpcUaClient.md` enumerating each counter and the event
|
||||
that bumps it; cross-link to the `driver-diagnostics` Admin RPC
|
||||
documented for Modbus (#154 pattern). Fixture: no opc-plc change
|
||||
required. Integration test exercises `IDriverDiagnostics` after
|
||||
forcing a session close. E2E: extend
|
||||
`scripts/e2e/test-opcuaclient.ps1` with a "diagnostics snapshot" stage
|
||||
that asserts publish/notification counters are non-zero after the
|
||||
subscribe stage.
|
||||
|
||||
---
|
||||
|
||||
#### PR-5: CRL / revocation handling (gap #20)
|
||||
|
||||
**Goal**: explicit revoked-cert handling in `CertificateValidator` plus a
|
||||
`RejectSHA1SignedCertificates` knob. Today the validator hooks
|
||||
`BadCertificateUntrusted` only — a revoked cert silently fails as
|
||||
"untrusted" with no operator-visible distinction.
|
||||
|
||||
**SDK API**:
|
||||
- `CertificateValidator.CertificateValidation` event — inspect
|
||||
`e.Error.StatusCode` for `BadCertificateRevoked`,
|
||||
`BadCertificateRevocationUnknown`,
|
||||
`BadCertificateIssuerRevocationUnknown`,
|
||||
`BadCertificatePolicyCheckFailed`
|
||||
- `SecurityConfiguration.RejectSHA1SignedCertificates`,
|
||||
`SecurityConfiguration.RejectUnknownRevocationStatus`,
|
||||
`SecurityConfiguration.MinimumCertificateKeySize` — direct config
|
||||
bool/int knobs already on the SDK type
|
||||
- `CertificateTrustList.AddCRL` / per-store CRL directories under
|
||||
`%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\`
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — `BuildApplicationConfigurationAsync`
|
||||
honours new options, validator handler distinguishes revoked vs untrusted
|
||||
in the surfaced error message
|
||||
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
|
||||
`RejectSHA1SignedCertificates`, `RejectUnknownRevocationStatus`,
|
||||
`MinimumCertificateKeySize`
|
||||
|
||||
**Tests**: feed a SHA1-signed test cert and a revoked cert through the
|
||||
validator with the new knobs on/off.
|
||||
|
||||
**Risks**: PKI directory layout changes — existing deployments need a
|
||||
migration note.
|
||||
|
||||
**Docs / fixture / e2e**: new "Certificate revocation and SHA1 rejection"
|
||||
subsection in `docs/drivers/OpcUaClient.md` documenting the CRL
|
||||
directory layout under `%LocalAppData%\OtOpcUa\pki\{trusted,issuers}\crl\`
|
||||
and the new options (with a migration note for existing PKI stores);
|
||||
cross-link from `docs/security.md`. Fixture: extend
|
||||
`OpcPlcFixture` / `Docker/docker-compose.yml` with an optional secured
|
||||
endpoint variant and a SHA1-signed test cert checked into the test
|
||||
project's resources for the validator unit test. Integration test
|
||||
exercises a revoked cert via a local CRL drop. E2E: add a
|
||||
`-Insecure:$false` smoke stage to `scripts/e2e/test-opcuaclient.ps1`
|
||||
that asserts a revoked cert produces a distinguishable error message.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Discovery & curation
|
||||
|
||||
#### PR-6: Discovery URL `FindServers` (gap #2)
|
||||
|
||||
**Goal**: accept a discovery URL (`opc.tcp://host:4840` pointing at the
|
||||
LDS or the server's own discovery endpoint) and surface advertised servers
|
||||
+ endpoints to the operator without manual policy/mode tuple copy.
|
||||
|
||||
**SDK API**:
|
||||
- `DiscoveryClient.CreateAsync(appConfig, new Uri(url), DiagnosticsMasks.None, ct)`
|
||||
- `DiscoveryClient.FindServersAsync(null, ct)` → `ApplicationDescription[]`
|
||||
- `DiscoveryClient.GetEndpointsAsync(null, ct)` per advertised `DiscoveryUrl`
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new internal
|
||||
`DiscoverServersAsync` helper; extend the Admin-side discovery RPC to
|
||||
invoke it (driver-diagnostics pattern from #154)
|
||||
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
|
||||
`DiscoveryUrl` knob (alternative to explicit `EndpointUrls` — when set
|
||||
the driver runs `FindServers` at init and feeds the result into the
|
||||
failover candidate list)
|
||||
|
||||
**Tests**: mock `DiscoveryClient` returning two advertised servers each
|
||||
with three endpoints; assert the candidate list reflects the policy/mode
|
||||
filter applied client-side.
|
||||
|
||||
**Risks**: `FindServers` itself usually requires `SecurityMode=None` —
|
||||
spec out in the doc that the discovery channel is unsecured even when
|
||||
the data channel will be encrypted.
|
||||
|
||||
**Docs / fixture / e2e**: new "Discovery URL (`FindServers`)" section in
|
||||
`docs/drivers/OpcUaClient.md` with the unsecured-discovery-vs-secured-
|
||||
data caveat called out; cross-link from `docs/Client.CLI.md` if a
|
||||
`discover` CLI command surfaces. Fixture: opc-plc already responds to
|
||||
`FindServers` on the same endpoint — `OpcPlcFixture` adds a discovery
|
||||
probe at collection init. Integration test exercises the helper against
|
||||
the live opc-plc container and asserts at least one
|
||||
`ApplicationDescription` returned. E2E: replace the hard-coded
|
||||
`-RemoteUrl` stage in `scripts/e2e/test-opcuaclient.ps1` with an
|
||||
optional `-DiscoveryUrl` mode that picks the first advertised endpoint.
|
||||
|
||||
---
|
||||
|
||||
#### PR-7: Selective import / namespace remap (gap #7)
|
||||
|
||||
**Goal**: per-branch include/exclude rules, namespace-URI remapping, and
|
||||
re-keyed BrowseNames — the curation surface every commercial aggregator
|
||||
ships.
|
||||
|
||||
**Approach**: extend `OpcUaClientDriverOptions` with a `Curation` section:
|
||||
- `IncludePaths: string[]` — glob or NodeId-rooted prefix list; only paths
|
||||
matching are imported
|
||||
- `ExcludePaths: string[]` — wins over Include (Include is allow-list,
|
||||
Exclude is block-list)
|
||||
- `NamespaceRemap: Dictionary<string,string>` — upstream NS URI →
|
||||
local-side alias for BrowseName generation
|
||||
- `RootAlias: string` — default `"Remote"`; replaces the hardcoded folder
|
||||
name today
|
||||
|
||||
**SDK API** — none new; this is pure local filtering inside
|
||||
`BrowseRecursiveAsync` and `EnrichAndRegisterVariablesAsync`.
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs`
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` —
|
||||
`BrowseRecursiveAsync` consults the rule set; helper
|
||||
`MapNamespaceForBrowseName` handles NS remap
|
||||
|
||||
**Tests**: synthetic browse tree, exercise include/exclude/remap each
|
||||
independently and combined; verify the cap accounting in
|
||||
`MaxDiscoveredNodes` excludes filtered nodes.
|
||||
|
||||
**Risks**: glob semantics — pin to a small subset (`*`, `?` only — no
|
||||
character classes or `**`) to keep the doc + behaviour simple.
|
||||
|
||||
**Docs / fixture / e2e**: new "Curation: include/exclude and namespace
|
||||
remap" section in `docs/drivers/OpcUaClient.md` with worked examples of
|
||||
each rule kind and the supported glob subset; update
|
||||
`docs/drivers/OpcUaClient-Test-Fixture.md` "Coverage map" with the new
|
||||
filtering rows. Fixture: extend `OpcPlcProfile` to enumerate which
|
||||
upstream namespaces are exercised so curation tests can target them.
|
||||
Integration test seeds an Include + Exclude + Remap rule and asserts
|
||||
the local tree reflects the filter. E2E: add a
|
||||
`-IncludePath` / `-NamespaceRemap` set of params to
|
||||
`scripts/e2e/test-opcuaclient.ps1` that asserts the local browse depth
|
||||
matches the rule.
|
||||
|
||||
---
|
||||
|
||||
#### PR-8: Type definition mirroring (gap #8)
|
||||
|
||||
**Goal**: walk the upstream `Types` folder (`ObjectTypes`,
|
||||
`VariableTypes`, `DataTypes`, `ReferenceTypes`) and project them into the
|
||||
local address space so downstream UI clients keep type-aware rendering and
|
||||
structured DataTypes decode correctly.
|
||||
|
||||
**SDK API**:
|
||||
- `Session.NodeCache.FetchNode(typeNodeId)` for type metadata
|
||||
- `Session.LoadDataTypeSystem` — for structured DataType encoding
|
||||
- `Session.FetchTypeTree(NodeIdCollection)` — populates the session's
|
||||
type cache from the server
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new pass-3 in `DiscoverAsync`
|
||||
that walks `i=86` (Types folder) under the curation rules, registers a
|
||||
parallel type subtree, and links variables to their TypeDefinition via
|
||||
HasTypeDefinition references on the address-space builder
|
||||
- `src/.../Core.Abstractions/IAddressSpaceBuilder.cs` — confirm whether
|
||||
the builder accepts type nodes; if not, extend it (this likely is a
|
||||
prerequisite — if so, it gets its own preceding PR-8a)
|
||||
|
||||
**Tests**: mock browse returning `BaseObjectType -> DerivedThing`;
|
||||
assert local builder receives the type node + the HasTypeDefinition link.
|
||||
|
||||
**Risks**: significant. Type mirroring touches `IAddressSpaceBuilder`
|
||||
which is a cross-cutting interface every driver depends on. If
|
||||
`IAddressSpaceBuilder` already supports type nodes (Galaxy has type-like
|
||||
templates), reuse that surface; otherwise this PR splits.
|
||||
|
||||
**Docs / fixture / e2e**: new "Type mirroring" section in
|
||||
`docs/drivers/OpcUaClient.md` documenting which type nodes get walked
|
||||
and how downstream UA clients see the HasTypeDefinition references; also
|
||||
note in `docs/Client.UI.md` that the Browse tree now shows mirrored
|
||||
types. Fixture: opc-plc already exposes the standard `Types` folder;
|
||||
extend `OpcPlcProfile` to assert at least one custom ObjectType is
|
||||
present. Integration test browses the local Types folder post-discovery
|
||||
and asserts the upstream type chain landed. No e2e change needed beyond
|
||||
extending the existing browse stage to walk under `Types`.
|
||||
|
||||
---
|
||||
|
||||
#### PR-9: Method node mirroring + `Call` passthrough (gap #9)
|
||||
|
||||
**Goal**: discover `NodeClass.Method` nodes in the browse pass, expose
|
||||
them on the local address space, and forward `Call` invocations as
|
||||
`Session.CallAsync` against the upstream node. The driver already calls
|
||||
`AcknowledgeableConditionType.Acknowledge` for A&C — generalize that path.
|
||||
|
||||
**SDK API**:
|
||||
- `Session.CallAsync(requestHeader, methodsToCall: CallMethodRequestCollection, ct)`
|
||||
returning `CallMethodResultCollection`
|
||||
- Browse already covers Method nodes by lifting the `NodeClassMask`; need
|
||||
to additionally browse `HasProperty` to discover `InputArguments` /
|
||||
`OutputArguments` for argument translation
|
||||
|
||||
**Files**:
|
||||
- `src/.../Core.Abstractions/IDriver.cs` — add `IMethodInvoker` capability
|
||||
interface (this is a NEW capability, not a tweak to an existing one)
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement
|
||||
`IMethodInvoker.InvokeAsync(string objectId, string methodId,
|
||||
IReadOnlyList<object?> inputs, ct)`; refactor `AcknowledgeAsync` to
|
||||
reuse the common path
|
||||
- `src/.../Server/...` node-manager — wire `IMethodInvoker` to the OPC UA
|
||||
server's `MethodNode.OnCallMethod` hook so downstream Call requests
|
||||
reach the driver
|
||||
|
||||
**Tests**: mock `Session.CallAsync` returning Good + an output collection;
|
||||
assert pass-through fidelity. Also assert per-argument `BadInvalidArgument`
|
||||
codes pass through.
|
||||
|
||||
**Risks**: high — adds a new capability interface. Other drivers that
|
||||
*could* support methods (Galaxy via `OnExecute` scripts, FOCAS via FOCAS
|
||||
commands) gain a clean extension point but each is its own follow-up.
|
||||
|
||||
**Docs / fixture / e2e**: new "Method nodes and Call passthrough"
|
||||
section in `docs/drivers/OpcUaClient.md` explaining how method calls
|
||||
flow through the aggregator (input/output argument translation, error-
|
||||
code passthrough); add a `call` command page to `docs/Client.CLI.md`
|
||||
covering the new path; mirror in `docs/Client.UI.md` if a UI surface
|
||||
ships. Fixture: opc-plc already exposes the standard
|
||||
`Server.GetMonitoredItems` method — `OpcPlcFixture` registers it as the
|
||||
canonical method-call target. Integration test in
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` invokes
|
||||
`Server.GetMonitoredItems` through the aggregator. E2E: add a
|
||||
`-MethodNodeId` stage to `scripts/e2e/test-opcuaclient.ps1` that calls
|
||||
the method through the local server and asserts the output matches the
|
||||
direct upstream call.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Change tracking
|
||||
|
||||
#### PR-10: Auto re-import on `ModelChangeEvent` (gap #10)
|
||||
|
||||
**Goal**: subscribe to `BaseModelChangeEventType` /
|
||||
`GeneralModelChangeEventType` on the upstream server's `i=2253` Server
|
||||
node so when the upstream topology changes (new tag added, type modified)
|
||||
the driver triggers a `ReinitializeAsync`-style re-import without
|
||||
operator action.
|
||||
|
||||
**SDK API**:
|
||||
- A second `Subscription` on the Session, monitoring `Server` node
|
||||
(`ObjectIds.Server`) with an `EventFilter` whose SelectClauses reference
|
||||
`BaseModelChangeEventType` and (optionally) `GeneralModelChangeEventType`
|
||||
Changes property
|
||||
- On notification: enqueue a debounced re-discover (don't react to every
|
||||
event during a bulk topology edit — coalesce 2-5s window)
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — add `_modelChangeSubscription`
|
||||
field; new `SubscribeModelChangesAsync` invoked at the end of
|
||||
`InitializeAsync`; debounce timer that calls `ReinitializeAsync` on the
|
||||
driver host
|
||||
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
|
||||
`WatchModelChanges: bool` (default true) +
|
||||
`ModelChangeDebounce: TimeSpan` (default 5s)
|
||||
|
||||
**Tests**: synthetic event injection on the mock Session's notification
|
||||
stream; assert one debounced re-import call regardless of N events
|
||||
arriving in the window.
|
||||
|
||||
**Risks**: re-import while a downstream client is mid-browse — needs
|
||||
serialization on `_gate` like the rest of the driver; document that
|
||||
clients see a brief gap in the address space during reload.
|
||||
|
||||
**Docs / fixture / e2e**: new "Auto re-import on ModelChangeEvent"
|
||||
section in `docs/drivers/OpcUaClient.md` documenting the debounce window,
|
||||
the `_gate` serialization, and the brief browse-gap during reload.
|
||||
Fixture: opc-plc supports runtime topology mutation via the
|
||||
`addnode`/`addtag` HTTP control endpoint — extend `OpcPlcFixture` with
|
||||
a helper that triggers a model change. Integration test asserts a
|
||||
single re-import call after a burst of synthetic model change events.
|
||||
E2E: add a "topology change" stage to
|
||||
`scripts/e2e/test-opcuaclient.ps1` that calls the opc-plc control
|
||||
endpoint, then asserts the local server reflects the new node within
|
||||
the debounce window.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Connectivity
|
||||
|
||||
#### PR-11: Reverse Connect (gap #1)
|
||||
|
||||
**Goal**: support server-initiated client connect for OT-DMZ outbound-only
|
||||
firewalls. The upstream server connects *to* us on a TCP listener; we
|
||||
respond as the client. Hard requirement for many regulated plant networks.
|
||||
|
||||
**SDK API**:
|
||||
- `Opc.Ua.Client.ReverseConnectManager` — manages a TCP listener on the
|
||||
configured port and dispatches incoming reverse-connect requests
|
||||
- `ReverseConnectManager.AddEndpoint(Uri reverseEndpoint)` — listener URI
|
||||
e.g. `opc.tcp://0.0.0.0:4844`
|
||||
- `ReverseConnectManager.WaitForConnection(serverUri, serverUri, ct)` —
|
||||
blocks until the configured server initiates a reverse connect
|
||||
- `Session.Create(appConfig, reverseConnection, endpoint, ...)` —
|
||||
alternative session-create overload accepting the
|
||||
`ITransportWaitingConnection` returned by the manager
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
|
||||
`ReverseConnect: { Enabled, ListenerUrl, ExpectedServerUri }` section
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — when reverse-connect is
|
||||
enabled, replace the failover sweep with `WaitForConnection` and fall
|
||||
through into the same session-create path
|
||||
- New helper `ReverseConnectListener` — owns the manager lifecycle, one
|
||||
listener per driver-host process (singleton across instances if multiple
|
||||
reverse-connect drivers are configured)
|
||||
|
||||
**Tests**: spin up a `ReverseConnectClient` test against an opc-plc
|
||||
container started with `--rc opc.tcp://host:4844` to verify end-to-end.
|
||||
Unit tests mock `ITransportWaitingConnection`.
|
||||
|
||||
**Risks**: highest of the plan. Reverse Connect changes the
|
||||
listen-vs-dial direction; if multiple OpcUaClient driver instances both
|
||||
listen on the same port the manager must multiplex. opc-plc supports
|
||||
reverse connect (`--rc` flag) so the integration test pattern from
|
||||
`docs/drivers/OpcUaClient-Test-Fixture.md` extends cleanly.
|
||||
|
||||
**Docs / fixture / e2e**: new "Reverse Connect" section in
|
||||
`docs/drivers/OpcUaClient.md` (create if missing) documenting the
|
||||
listener URL config, the OT-DMZ outbound-only use case, and the shared-
|
||||
listener singleton model; update `docs/drivers/OpcUaClient-Test-Fixture.md`
|
||||
with the new "Reverse Connect coverage" row. Fixture: extend
|
||||
`Docker/docker-compose.yml` with an `opc-plc-rc` service variant that
|
||||
adds `--rc opc.tcp://host.docker.internal:4844`; `OpcPlcFixture` gains
|
||||
a `[CollectionDefinition]` that wires up the reverse-connect listener
|
||||
on the test side. Integration test asserts a session opens via the
|
||||
reverse path. E2E: add a `-ReverseConnect` switch to
|
||||
`scripts/e2e/test-opcuaclient.ps1` that flips the driver to listener
|
||||
mode and verifies the bridge stage still passes.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Historical & redundancy
|
||||
|
||||
#### PR-12: `IHistoryProvider.ReadEventsAsync` interface fix + driver impl (gap #12)
|
||||
|
||||
**Goal**: extend `IHistoryProvider.ReadEventsAsync` to carry an
|
||||
`EventFilter SelectClauses` parameter so HistoryRead Events can return
|
||||
the right field projection, and implement the OPC UA Client passthrough.
|
||||
|
||||
**This is a cross-driver concern.** `IHistoryProvider` lives in
|
||||
`Core.Abstractions` and every driver that opts into history (Galaxy,
|
||||
OpcUaClient, plus any future historian-backed Tier-A driver) inherits the
|
||||
default. Changing the signature is source-breaking — coordinate as one PR
|
||||
that:
|
||||
1. Adds the `IReadOnlyList<EventFieldProjection>` (or equivalent
|
||||
abstract `EventFilterSpec`) parameter
|
||||
2. Updates Galaxy's existing override (currently the only override) to
|
||||
honour the projection (best-effort — the Galaxy A&E log has a fixed
|
||||
field set so most projections degrade to the default columns)
|
||||
3. Lands the OpcUaClient passthrough using `Session.HistoryReadAsync` with
|
||||
`ReadEventDetails`
|
||||
|
||||
**SDK API**:
|
||||
- `ReadEventDetails { StartTime, EndTime, NumValuesPerNode, Filter }`
|
||||
- `Session.HistoryReadAsync` is already the call we use for Raw — pass
|
||||
`new ExtensionObject(new ReadEventDetails { ... })` for events
|
||||
- `HistoryEvent.Events: HistoryEventFieldList[]` — unwrap into
|
||||
`HistoricalEvent` records
|
||||
|
||||
**Files**:
|
||||
- `src/.../Core.Abstractions/IHistoryProvider.cs` — interface change
|
||||
- `src/.../Driver.Galaxy.../*HistoryProvider*.cs` — adjust signature
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — implement
|
||||
`ReadEventsAsync`; reuse `ExecuteHistoryReadAsync` shape
|
||||
- Server-side history facade — propagate the new parameter
|
||||
|
||||
**Tests**: integration test against opc-plc with
|
||||
`--alm` (alarm sim already enabled per the fixture doc) — verify the
|
||||
SelectClause projection comes back correctly.
|
||||
|
||||
**Risks**: the cross-driver interface change is the riskiest single
|
||||
ergonomic call in this plan. If we can't fit the new parameter without
|
||||
breaking every driver's `IHistoryProvider` impl, fall back to a sibling
|
||||
`IEventHistoryProvider` interface and only the OPC UA Client + Galaxy
|
||||
implement it. **Decide this in the PR review.**
|
||||
|
||||
**Docs / fixture / e2e**: new "HistoryRead Events" section in
|
||||
`docs/drivers/OpcUaClient.md` documenting the `EventFilter`-aware
|
||||
passthrough; update `docs/Client.CLI.md` `historyread` page to cover
|
||||
event-mode reads. **Cross-driver doc updates** (this PR adds an
|
||||
"`IHistoryProvider.ReadEventsAsync` signature change — see
|
||||
`docs/plans/opcuaclient-plan.md` PR-12" note to every other driver
|
||||
plan that has a history surface): `docs/plans/abcip-plan.md`,
|
||||
`docs/plans/ablegacy-plan.md`, `docs/plans/focas-plan.md`,
|
||||
`docs/plans/s7-plan.md`, `docs/plans/twincat-plan.md`, the Galaxy plan
|
||||
family (`docs/plans/galaxy-*.md` if/when present, and the LMX equivalent
|
||||
if it lands), and any Modbus plan. Galaxy is the only existing
|
||||
implementor and gets a real signature update in this PR; the others
|
||||
get a heads-up note so future work tracks the new shape. Fixture: opc-
|
||||
plc runs with `--alm` already (per existing fixture doc) — no compose
|
||||
change. Integration test issues a HistoryRead Events with a non-default
|
||||
SelectClause and asserts the projected fields. E2E: extend
|
||||
`scripts/e2e/test-opcuaclient.ps1` with a "history events" stage
|
||||
gated on the `--alm` simulator producing at least one event.
|
||||
|
||||
---
|
||||
|
||||
#### PR-13: Full Aggregate function set (gap #13)
|
||||
|
||||
**Goal**: extend `HistoryAggregateType` from the 5 enum values today
|
||||
(Average/Minimum/Maximum/Total/Count) to the OPC UA Part 13 standard
|
||||
catalog of 30+ aggregates that historian-class clients expect.
|
||||
|
||||
**SDK API**: `ObjectIds.AggregateFunction_*` constants — one per
|
||||
aggregate. The SDK already exposes them; this is pure mapping work.
|
||||
|
||||
Aggregates to add (Part 13 §5):
|
||||
- `TimeAverage`, `TimeAverage2`
|
||||
- `Interpolative`
|
||||
- `MinimumActualTime`, `MaximumActualTime`, `Range`, `Range2`
|
||||
- `AnnotationCount`, `DurationGood`, `DurationBad`,
|
||||
`PercentGood`, `PercentBad`
|
||||
- `WorstQuality`, `WorstQuality2`
|
||||
- `StandardDeviationSample`, `StandardDeviationPopulation`,
|
||||
`VarianceSample`, `VariancePopulation`
|
||||
- `NumberOfTransitions`
|
||||
- `Start`, `End`, `Delta`, `StartBound`, `EndBound`
|
||||
- `DurationInStateZero`, `DurationInStateNonZero`
|
||||
|
||||
**Files**:
|
||||
- `src/.../Core.Abstractions/IHistoryProvider.cs` — extend
|
||||
`HistoryAggregateType` enum (additive — existing values keep their
|
||||
ordinal)
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` —
|
||||
`MapAggregateToNodeId` switch grows; default arm rejects `out of range`
|
||||
|
||||
**Tests**: parametrized unit test sweeping every enum value — assert
|
||||
each maps to a non-null `NodeId` in the SDK's well-known set.
|
||||
|
||||
**Risks**: low — this is mapping work. Drivers without a real historian
|
||||
(everything except Galaxy + OpcUaClient) keep throwing `NotSupported`.
|
||||
|
||||
**Docs / fixture / e2e**: extend the "HistoryRead aggregates" section in
|
||||
`docs/drivers/OpcUaClient.md` with the full Part 13 catalog and which
|
||||
aggregates require server-side support; update
|
||||
`docs/Client.CLI.md` `historyread` page enumerating the new
|
||||
`--aggregate` values. Fixture: opc-plc historian support is limited —
|
||||
flag in `docs/drivers/OpcUaClient-Test-Fixture.md` that the new
|
||||
aggregates are unit-tested via the SDK's well-known NodeId set, not
|
||||
exercised wire-side. Integration test sweeps every enum value and
|
||||
asserts the mapping; gated-skip for aggregates the live opc-plc image
|
||||
doesn't honour. No e2e change.
|
||||
|
||||
---
|
||||
|
||||
#### PR-14: `ServerUriArray` redundant failover (gap #14)
|
||||
|
||||
**Goal**: read upstream `Server.ServerArray` /
|
||||
`ServerStatus.ServerArray` and `ServerRedundancyType.RedundancySupport` at
|
||||
session activation; when the upstream server advertises non-`None`
|
||||
redundancy, fail over mid-session on `ServiceLevel` drop without losing
|
||||
client subscriptions. Today our `EndpointUrls` is a one-shot connect-
|
||||
attempt list, not a live redundancy group.
|
||||
|
||||
**SDK API**:
|
||||
- `Session.ReadValueAsync(VariableIds.Server_ServerStatus_ServerArray, ct)`
|
||||
→ URI list
|
||||
- `Session.ReadValueAsync(VariableIds.Server_ServiceLevel, ct)` polled or
|
||||
subscribed via MonitoredItem
|
||||
- Subscribe `Server_ServiceLevel` on the existing alarm subscription so
|
||||
drops propagate via the publish channel
|
||||
- On low-`ServiceLevel`: open a parallel session against the next URI in
|
||||
`ServerArray`, `Session.TransferSubscriptionsAsync(otherSession, ...)`
|
||||
the live subscriptions, swap `Session` reference
|
||||
|
||||
**Files**:
|
||||
- `src/.../OpcUaClient/OpcUaClientDriver.cs` — new
|
||||
`MonitorServerRedundancyAsync` method; integrate with the existing
|
||||
`OnKeepAlive` / `SessionReconnectHandler` machinery so reconnect and
|
||||
redundancy-failover share the subscription-transfer code path
|
||||
- `src/.../OpcUaClient/OpcUaClientDriverOptions.cs` — add
|
||||
`Redundancy: { Enabled, ServiceLevelThreshold (default 200) }`
|
||||
|
||||
**Tests**: with two opc-plc containers behind the driver,
|
||||
artificially drop ServiceLevel on the active one and assert the
|
||||
secondary takes over; assert subscription handles stay valid.
|
||||
|
||||
**Risks**: redundancy is the second-riskiest item after Reverse Connect.
|
||||
The SDK's `TransferSubscriptions` has known edge cases when the
|
||||
secondary's `SecureChannel` rejects the source-channel's authentication
|
||||
token; doc that the secondary must trust the same client cert as the
|
||||
primary.
|
||||
|
||||
**Docs / fixture / e2e**: new "Upstream redundancy (`ServerArray`)"
|
||||
section in `docs/drivers/OpcUaClient.md` with the ServiceLevel
|
||||
threshold, the shared-cert prerequisite for `TransferSubscriptions`,
|
||||
and the ops runbook for forcing a failover; cross-link from
|
||||
`docs/Redundancy.md` (which today covers OUR server's redundancy —
|
||||
add a "vs upstream-side redundancy" note). Fixture: extend
|
||||
`Docker/docker-compose.yml` with a second `opc-plc-secondary` service
|
||||
on a different port; `OpcPlcFixture` gains a multi-endpoint variant.
|
||||
Integration test drops the active server's ServiceLevel and asserts
|
||||
the secondary takes over with subscription handles intact. E2E: add a
|
||||
`-PrimaryUrl` / `-SecondaryUrl` pair to
|
||||
`scripts/e2e/test-opcuaclient.ps1` (and matching keys to
|
||||
`scripts/e2e/e2e-config.sample.json`) that scripts a primary stop +
|
||||
asserts the bridge stage continues to pass.
|
||||
|
||||
---
|
||||
|
||||
## Documentation, fixture, and e2e impact
|
||||
|
||||
Consolidated index of every doc page, fixture asset, and e2e script touched
|
||||
by the plan above. Authoritative for review — if a PR's `Docs / fixture /
|
||||
e2e` line references a path not listed here, that's a checklist gap.
|
||||
|
||||
### Driver user docs
|
||||
|
||||
- `docs/drivers/OpcUaClient.md` — **create on first PR that needs it
|
||||
(PR-1)** if not present, then extend with one section per PR-1 through
|
||||
PR-14 covering: subscription tuning, per-tag deadband, OperationLimits
|
||||
handling, diagnostics counters, CRL/SHA1, FindServers, curation,
|
||||
type mirroring, methods, ModelChangeEvent, Reverse Connect, history
|
||||
events, aggregates, upstream redundancy.
|
||||
- `docs/drivers/OpcUaClient-Test-Fixture.md` — coverage map updated for
|
||||
curation (PR-7), Reverse Connect (PR-11), aggregates note (PR-13),
|
||||
redundancy multi-endpoint variant (PR-14).
|
||||
- `docs/Client.CLI.md` — extended for subscribe deadband syntax (PR-2),
|
||||
any `discover` command (PR-6), `call` command (PR-9), `historyread`
|
||||
event mode (PR-12), `--aggregate` enum expansion (PR-13).
|
||||
- `docs/Client.UI.md` — extended for Subscriptions tab deadband fields
|
||||
(PR-2), Browse-tree type rendering note (PR-8), Method-call surface
|
||||
(PR-9) if it ships.
|
||||
- `docs/security.md` — cross-link from PR-5 (CRL/SHA1 knobs).
|
||||
- `docs/Redundancy.md` — cross-link from PR-14 (note distinguishing
|
||||
server-side redundancy from upstream-side redundancy).
|
||||
|
||||
### Fixture assets
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml`
|
||||
— add `opc-plc-rc` (PR-11) and `opc-plc-secondary` (PR-14) service
|
||||
variants; optional secured endpoint (PR-5).
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcFixture.cs`
|
||||
— discovery probe at collection init (PR-6), reverse-connect listener
|
||||
(PR-11), multi-endpoint variant (PR-14), model-change helper (PR-10).
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcPlcProfile.cs`
|
||||
— flag noisy analogs for deadband (PR-2), enumerate exercised
|
||||
namespaces for curation (PR-7), record at least one custom ObjectType
|
||||
(PR-8).
|
||||
- New integration tests added per PR; all live under the existing
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/`
|
||||
collection.
|
||||
- Test certs (PR-5): SHA1-signed + revoked test fixtures checked into
|
||||
the unit-test project's resources.
|
||||
|
||||
### E2E scripts
|
||||
|
||||
- `scripts/e2e/test-opcuaclient.ps1` — new stages added per PR (subscription
|
||||
tuning PR-1, deadband PR-2, diagnostics PR-4, CRL PR-5, discovery
|
||||
PR-6, curation PR-7, method call PR-9, topology change PR-10,
|
||||
reverse connect PR-11, history events PR-12, redundancy failover
|
||||
PR-14). The script is the single integration point for every
|
||||
driver-level e2e — keep the stages ordered top-down by phase.
|
||||
- `scripts/e2e/e2e-config.sample.json` — new keys: `deadband`,
|
||||
`discoveryUrl`, `includePath`, `namespaceRemap`, `methodNodeId`,
|
||||
`reverseConnect`, `primaryUrl`, `secondaryUrl`.
|
||||
- `scripts/e2e/test-all.ps1` — no structural change; the existing
|
||||
`opcuaclient` block forwards new params after wiring them through
|
||||
`e2e-config.sample.json`.
|
||||
|
||||
### Cross-driver impact (PR-12 — `IHistoryProvider.ReadEventsAsync`)
|
||||
|
||||
PR-12 changes the `IHistoryProvider.ReadEventsAsync` signature in
|
||||
`Core.Abstractions` (or introduces a sibling `IEventHistoryProvider`
|
||||
— pinned in PR-12 review per Open Question 2). That decision is
|
||||
source-breaking for every driver that opts into history. PR-12 must
|
||||
add an explicit "interface change — adopt new signature when this
|
||||
driver implements `ReadEventsAsync`" note to:
|
||||
|
||||
- `docs/plans/abcip-plan.md`
|
||||
- `docs/plans/ablegacy-plan.md`
|
||||
- `docs/plans/focas-plan.md`
|
||||
- `docs/plans/s7-plan.md`
|
||||
- `docs/plans/twincat-plan.md`
|
||||
- The Galaxy plan family — `docs/plans/galaxy-*.md` if/when those
|
||||
pages exist; Galaxy is the only current implementor and gets the
|
||||
real signature update in PR-12, not just a note.
|
||||
- The LMX plan — `docs/plans/lmx-*.md` if/when it lands (current state:
|
||||
the LMX driver's history surface is implicit through Galaxy; revisit
|
||||
during PR-12 review).
|
||||
- A Modbus plan page if/when one exists; Modbus does not implement
|
||||
history today but the heads-up note tracks the cross-driver shape.
|
||||
|
||||
The cross-driver note text should be a one-paragraph "Heads up: the
|
||||
`IHistoryProvider.ReadEventsAsync` interface gained an
|
||||
`EventFilterSpec` parameter in OpcUaClient PR-12 (`docs/plans/opcuaclient-plan.md`).
|
||||
If/when this driver implements event-history, adopt the new signature."
|
||||
This pattern keeps each driver plan stable while the cross-cutting
|
||||
breakage is owned by one PR.
|
||||
|
||||
---
|
||||
|
||||
## Skip-rated items (for context)
|
||||
|
||||
These featuregaps rows are **Build = No** and intentionally omitted from
|
||||
the plan above:
|
||||
|
||||
| # | Gap | Why we're skipping |
|
||||
| :---: | --- | --- |
|
||||
| 3 | Multicast / LDS-ME registration | Server-side responsibility, not aggregator's. |
|
||||
| 4 | GDS push management (Part 12) | Significant infra; rare for our deployment scale. |
|
||||
| 11 | HistoryUpdate / Modified / Annotation passthrough | MES backfill scope; defer. |
|
||||
| 16 | Connection / session pooling for multi-instance scale-out | Premature; current per-instance model is simple and adequate. |
|
||||
| 18 | Kerberos / OAuth2 / JWT identity | Significant security work; defer until AD integration drives it (separate workstream). |
|
||||
| 19 | Write attribute scope beyond `Value` | Niche; rarely used in OPC UA practice. |
|
||||
|
||||
If any of these get prioritized later they slot cleanly between the phases
|
||||
above — none have prerequisites among the Build = Yes items.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **`ISubscribable` overload vs new method (PR-2)**: per-tag spec
|
||||
carrier is needed for deadband; do we extend the existing
|
||||
`SubscribeAsync` overload or add `SubscribeWithSpecsAsync`? The
|
||||
former is source-breaking but cleaner; the latter is additive but
|
||||
leaves two parallel paths.
|
||||
2. **`IHistoryProvider.ReadEventsAsync` shape (PR-12)**: does the
|
||||
`EventFilterSpec` parameter live on `IHistoryProvider` (one interface,
|
||||
every driver gets it) or on a sibling `IEventHistoryProvider` (two
|
||||
interfaces, only event-history drivers implement)? Memory entry
|
||||
suggests the former; preference depends on whether non-OPC-UA drivers
|
||||
ever expect to project arbitrary event fields. **Pin this in PR-12
|
||||
review.**
|
||||
3. **`IMethodInvoker` capability (PR-9)**: does this become the 9th
|
||||
capability interface (currently 8/8) or is it folded into
|
||||
`IWritable` as a method-invoke variant? Adding a 9th interface is
|
||||
the cleaner model and matches the spec layering.
|
||||
4. **Type mirroring address-space surface (PR-8)**: does
|
||||
`IAddressSpaceBuilder` already accept type nodes? If yes, PR-8 is
|
||||
straightforward; if no, it splits into a prerequisite PR-8a that
|
||||
extends the builder, then PR-8b for the OPC UA Client wire-up. The
|
||||
answer determines whether PR-8 ships in Phase 2 or slips to a later
|
||||
phase.
|
||||
5. **Reverse Connect listener ownership (PR-11)**: one listener per
|
||||
driver instance (port collision when multiple reverse-connect
|
||||
drivers run in the same process) vs one shared listener with a
|
||||
`expectedServerUri` dispatcher. Shared is the right answer; pin
|
||||
the singleton lifetime to the driver-host.
|
||||
6. **Phase 1 ship order**: PR-1, PR-3, PR-4, PR-5 are independent and can
|
||||
land in parallel. PR-2 depends on the `ISubscribable` interface
|
||||
decision (Q1) — recommend landing PR-1 first to validate the
|
||||
`OpcUaSubscriptionDefaults` shape, then PR-2.
|
||||
807
docs/plans/s7-plan.md
Normal file
807
docs/plans/s7-plan.md
Normal file
@@ -0,0 +1,807 @@
|
||||
# S7 Driver — Implementation Plan
|
||||
|
||||
> Source of gap analysis: [featuregaps.md → S7](../featuregaps.md#s7-siemens-s7-3004001200--1500)
|
||||
>
|
||||
> Covers Build = Yes items only. Skip-rated rows are noted at the end for context.
|
||||
|
||||
## Summary
|
||||
|
||||
The S7 driver (`src/ZB.MOM.WW.OtOpcUa.Driver.S7/`) ships a working scaffold over
|
||||
**S7netplus 0.20**: ISO-on-TCP / S7comm, single-connection-per-PLC (`SemaphoreSlim`),
|
||||
DB / M / I / Q / T / C address parsing, atomic scalar reads/writes for Bool / Byte
|
||||
/ I16 / U16 / I32 / U32 / F32, polled `ISubscribable` overlay, `IHostConnectivityProbe`
|
||||
via `ReadStatusAsync`, and a Snap7-server-backed CI fixture on `localhost:1102`.
|
||||
|
||||
The 16 Build = Yes gaps fall into six tractable phases. **The hard one is gap #1
|
||||
(S7-1500 Optimized DB / Symbolic addressing)** — S7netplus speaks classic S7comm
|
||||
only and cannot reach optimized DBs at all. Phase 6 calls that out as an explicit
|
||||
architectural decision: ship the constraint as documentation and the rest as
|
||||
S7netplus-compatible features, *or* fork to a library that supports S7Plus
|
||||
(Sharp7-fork, Snap7 v2, custom S7Plus). Phases 1-5 do not depend on that decision
|
||||
and are landable on the current S7netplus base.
|
||||
|
||||
Every PR ships unit-test coverage and — where wire semantics matter — extends the
|
||||
Snap7-server profile in `Docker/server.py` so the integration fixture exercises
|
||||
the new path. PRs that need real S7-1500 firmware features the simulator doesn't
|
||||
mimic (PUT/GET protection, password-tier auth, SZL diagnostic buffer) call that
|
||||
out and gate the live-firmware test on the dev-box S7-1500 lab rig.
|
||||
|
||||
Architectural invariants we explicitly preserve:
|
||||
|
||||
- Single connection per PLC; `_gate` (SemaphoreSlim) serializes every PDU.
|
||||
- Strict address-parse-at-init; bad config fails fast with `FormatException`.
|
||||
- PUT/GET-disabled mapped to sticky `BadDeviceFailure`, not Polly-retried.
|
||||
- 100 ms minimum publishing interval (matches CPU mailbox scan reality).
|
||||
- `WriteIdempotent` per-tag flag is the only retry-policy lever.
|
||||
|
||||
## Phased delivery
|
||||
|
||||
| Phase | Theme | PRs | Gaps closed |
|
||||
|------:|-------|-----|-------------|
|
||||
| 1 | Data-type correctness | PR-S7-A1..A5 | #7, #8, #9, #19 |
|
||||
| 2 | Performance — multi-tag PDU packing | PR-S7-B1..B2 | #3, #22 |
|
||||
| 3 | Operability knobs | PR-S7-C1..C5 | #2, #4, #20, #21, #24 |
|
||||
| 4 | Workflow — symbol import + UDTs | PR-S7-D1..D3 | #5, #6, #10 |
|
||||
| 5 | Diagnostics & security | PR-S7-E1..E2 | #11, #14 |
|
||||
| 6 | S7-1500 Optimized DB / Symbolic | PR-S7-F (decision) | #1 |
|
||||
|
||||
Phases 1-3 run sequentially because Phase 2 packing and Phase 3 deadbands are
|
||||
both keyed off the type-decode work in Phase 1. Phase 4 (UDT/symbol import) is
|
||||
parallelizable with Phase 5; Phase 6 is gated on the library-choice decision
|
||||
in Open Questions (a).
|
||||
|
||||
---
|
||||
|
||||
## Per-PR detail
|
||||
|
||||
### Phase 1 — Data-type correctness
|
||||
|
||||
#### PR-S7-A1 — 64-bit scalar types (LInt / ULInt / LReal / LWord)
|
||||
|
||||
Closes gap #9. `Float64`/`Int64`/`UInt64` cases in `S7Driver.ReadOneAsync`/
|
||||
`WriteOneAsync` currently throw `NotSupportedException`.
|
||||
|
||||
- **Files**: `S7Driver.cs` (read + write switch), `S7DriverOptions.cs` (extend
|
||||
`S7Size` with `LWord` for 8-byte access), `S7AddressParser.cs` (accept `DBL` /
|
||||
`LD` size suffix; S7netplus encodes 8-byte access via byte-array reads, so the
|
||||
parser converts `DB1.LD0` to a byte-range read internally).
|
||||
- **Tests**: unit decode tests for the byte-pattern → `long` / `ulong` / `double`
|
||||
conversion; Snap7-server profile gets `f64` and `i64` seed types.
|
||||
- **Risks**: S7netplus's `ReadAsync(string)` does not accept `LD` natively;
|
||||
fallback path is `Plc.ReadBytes(DataType.DataBlock, db, byteOffset, 8)` then
|
||||
`BitConverter` with explicit endian flip (S7 is big-endian on the wire,
|
||||
`BitConverter` is little-endian on x86/x64).
|
||||
- **Effort**: M (3-4 days incl. tests).
|
||||
- **Deps**: none.
|
||||
- **Docs / fixture / e2e**: extends the type-mapping table in `docs/v2/s7.md`
|
||||
with `LInt` / `ULInt` / `LReal` / `LWord` rows; adds the new sizes
|
||||
(`LInt`, `ULInt`, `LReal`) to the `read` / `write` cookbook in
|
||||
`docs/Driver.S7.Cli.md`; updates `docs/drivers/S7-Test-Fixture.md`
|
||||
§"What it actually covers" to list the new 64-bit types and removes them
|
||||
from §5 "Data types beyond the scalars"; extends the snap7 seed-type set
|
||||
in `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py`
|
||||
with `i64`, `u64`, `f64` cases; adds seeds at known offsets
|
||||
(e.g. `DB1.DBL40` for i64, `DB1.DBL48` for f64) to
|
||||
`Docker/profiles/s7_1500.json`; adds `S7_1500Profile` constants for the
|
||||
new tags + a `Driver_reads_seeded_64bit_batch` smoke test in
|
||||
`S7_1500SmokeTests`; adds an LInt loopback assertion to
|
||||
`scripts/e2e/test-s7.ps1`.
|
||||
|
||||
#### PR-S7-A2 — STRING / WSTRING / CHAR / WCHAR
|
||||
|
||||
Closes gap #8 (string portion). S7 `STRING(n)` is `[max-len][actual-len][bytes...]`
|
||||
(2-byte header + ASCII). `WSTRING(n)` is 4-byte header + UTF-16BE bytes. `CHAR`
|
||||
is 1 byte; `WCHAR` is 2 bytes UTF-16BE.
|
||||
|
||||
- **Files**: `S7Driver.cs` (new `ReadStringAsync` / `WriteStringAsync` private
|
||||
helpers using `Plc.ReadBytes` for raw byte-range fetch), `S7DriverOptions.cs`
|
||||
(already has `StringLength`; add `S7DataType.WString`, `Char`, `WChar`).
|
||||
- **Tests**: unit tests for header parsing including the "actual-len > max-len"
|
||||
PLC bug case (clamp on read, reject on write); Snap7 `ascii` seed type already
|
||||
exists, add `wstring` seed.
|
||||
- **Risks**: write must respect the configured `StringLength` to avoid overrunning
|
||||
the DB; mismatched max-len is a common field bug.
|
||||
- **Effort**: M.
|
||||
- **Deps**: PR-S7-A1 (byte-range read helper lands there).
|
||||
- **Docs / fixture / e2e**: extends the type-mapping section in
|
||||
`docs/v2/s7.md` with `STRING(n)` / `WSTRING(n)` / `CHAR` / `WCHAR`
|
||||
layouts (2-byte vs 4-byte header, UTF-16BE encoding, the "actual-len >
|
||||
max-len" PLC bug); extends the `read` / `write` cookbook in
|
||||
`docs/Driver.S7.Cli.md` with `--type WString` / `--type Char` / `--type
|
||||
WChar` examples and the `--string-length` flag for WString; updates
|
||||
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" to list
|
||||
ascii/wstring/char/wchar; adds `wstring`, `char`, `wchar` seed types to
|
||||
`Docker/server.py` (existing `ascii` covers STRING); seeds a
|
||||
`DB1.WSTRING[256]` and a `DB1.CHAR[300]` in
|
||||
`Docker/profiles/s7_1500.json`; adds `Driver_round_trips_string_types`
|
||||
smoke test exercising read + write of every variant; adds a string
|
||||
round-trip assertion to `scripts/e2e/test-s7.ps1`.
|
||||
|
||||
#### PR-S7-A3 — DTL / DATE_AND_TIME / S5TIME / TIME / TOD / DATE
|
||||
|
||||
Closes gap #8 (date/time portion).
|
||||
|
||||
- DTL is 12 bytes: year(u16) / month / day / weekday / hour / minute / second / nanos(u32).
|
||||
- DATE_AND_TIME (DT) is 8 bytes BCD: yy mm dd hh mm ss msH msL+dow.
|
||||
- S5TIME is 16-bit BCD with a 2-bit time-base.
|
||||
- TIME is `Int32` ms since 1972 (S7-300/400) or signed-ms duration (S7-1200/1500).
|
||||
- TOD is `UInt32` ms since midnight; DATE is `UInt16` days since 1990-01-01.
|
||||
|
||||
- **Files**: `S7Driver.cs` + new `S7DateTimeCodec.cs` static class encapsulating
|
||||
every encode/decode (keep the driver lean; codec is unit-testable in isolation).
|
||||
- **Tests**: round-trip tests per type with golden byte vectors taken from the
|
||||
Siemens "STEP 7 V18 — Programming Reference" document. Snap7-server seed
|
||||
profile gains `dtl`, `dt`, `s5time`, `time` types.
|
||||
- **Risks**: BCD parsing must reject invalid month/day combinations; PLC programs
|
||||
occasionally write 0x00 0x00 ... when uninitialized — surface as `BadOutOfRange`
|
||||
rather than parsing to year 0.
|
||||
- **Effort**: L (4-5 days incl. all six types and the golden-vector suite).
|
||||
- **Deps**: PR-S7-A1.
|
||||
- **Docs / fixture / e2e**: extends `docs/v2/s7.md` with a new "Date / time
|
||||
types" subsection documenting DTL / DT (BCD) / S5TIME / TIME / TOD /
|
||||
DATE byte layouts and the S7-300/400 vs S7-1200/1500 TIME-encoding
|
||||
split; adds `--type Dtl` / `--type DateAndTime` / `--type S5Time` /
|
||||
`--type Time` / `--type TimeOfDay` / `--type Date` to the
|
||||
`docs/Driver.S7.Cli.md` cookbook; updates
|
||||
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" with the
|
||||
new datetime types and removes "DTL / DATE_AND_TIME" from §5 "Data
|
||||
types beyond the scalars"; adds `dtl`, `dt`, `s5time`, `time`, `tod`,
|
||||
`date` seed types to `Docker/server.py` with golden-byte vectors
|
||||
documented in comments; seeds `DB1.DTL[260]`, `DB1.DT[272]`,
|
||||
`DB1.S5TIME[280]`, `DB1.TIME[284]`, `DB1.TOD[288]`, `DB1.DATE[292]` in
|
||||
`Docker/profiles/s7_1500.json`; adds
|
||||
`S7DateTimeCodecTests` (unit) + `Driver_round_trips_datetime_types`
|
||||
smoke test; no `scripts/e2e/test-s7.ps1` change required (CLI cookbook
|
||||
examples cover the manual surface).
|
||||
|
||||
#### PR-S7-A4 — Array tags (ValueRank=1)
|
||||
|
||||
Closes gap #7. `S7TagDefinition` currently has no array dimension; `MapDataType`
|
||||
hard-codes `IsArray: false`.
|
||||
|
||||
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with `ArrayDim` int?
|
||||
and `ElementCount` int?), `S7Driver.cs` (read path: detect array tag, issue
|
||||
one byte-range read covering N elements, slice client-side; write path: same
|
||||
in reverse), `DiscoverAsync` reports `IsArray: true, ArrayDim: [N]`.
|
||||
- **Tests**: unit tests for `Array[0..9] of Int` and `Array[0..9] of Real`;
|
||||
Snap7-server profile adds an array seed type. Round-trip array-write test
|
||||
proves slice ordering.
|
||||
- **Risks**: S7-1500 supports multi-dim arrays; declare ValueRank=1 only and
|
||||
document multi-dim as a follow-up. Array-of-UDT lands with PR-S7-D2.
|
||||
- **Effort**: M.
|
||||
- **Deps**: PR-S7-A1 (byte-range reads).
|
||||
- **Docs / fixture / e2e**: adds an "Array tags (ValueRank=1)" subsection
|
||||
to `docs/v2/s7.md` documenting `Array[0..N]` syntax + the multi-dim
|
||||
follow-up note; extends `docs/Driver.S7.Cli.md` with an
|
||||
`--array-count N` flag in the `read` / `write` cookbook and worked
|
||||
examples for `Array[0..9] of Int` and `Array[0..9] of Real`; updates
|
||||
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" to list
|
||||
array round-trips and removes "arrays of structs" from §5 (struct
|
||||
arrays land in PR-S7-D2); extends `Docker/server.py` with an `array`
|
||||
meta-seed-type that takes an inner-type + count and lays out N elements
|
||||
contiguously; seeds `DB1.ArrayInt[300]` (10×Int) and
|
||||
`DB1.ArrayReal[320]` (10×Real) in `Docker/profiles/s7_1500.json`;
|
||||
adds `Driver_round_trips_array_int10` + `Driver_round_trips_array_real10`
|
||||
smoke tests proving slice ordering; adds an array round-trip assertion
|
||||
to `scripts/e2e/test-s7.ps1`.
|
||||
|
||||
#### PR-S7-A5 — LOGO! 8 + S7-200 V-memory area
|
||||
|
||||
Closes gap #19. `S7AddressParser` currently rejects the `V` area letter.
|
||||
|
||||
- **Files**: `S7AddressParser.cs` (add `V` case → maps to `S7Area.DataBlock` with
|
||||
`DbNumber=1` for S7-200 / DbNumber per LOGO! VM-mapping table; document the
|
||||
conversion), `S7DriverOptions.cs` (note CpuType-dependent meaning of V).
|
||||
- **Tests**: unit tests for `VW0` / `VD4` / `V0.0` parsing, both S7-200 and
|
||||
LOGO! conventions; document caller responsibility to set `CpuType.S7200` or
|
||||
`S7200Smart`.
|
||||
- **Risks**: LOGO! VM base address differs by firmware (V0=0 vs V0=1024 depending
|
||||
on block); document the offset table rather than auto-detecting.
|
||||
- **Effort**: S (1-2 days, mostly parser + tests; no wire changes).
|
||||
- **Deps**: none.
|
||||
- **Docs / fixture / e2e**: adds a "LOGO! 8 / S7-200 V-memory" subsection
|
||||
to `docs/v2/s7.md` covering the `V` area letter, the `S7200` /
|
||||
`S7200Smart` CpuType pre-requisite, the LOGO! VM-mapping table by
|
||||
firmware band, and the "V0 = DB1.DBX0.0" semantic; extends the address
|
||||
grammar cheat sheet in `docs/Driver.S7.Cli.md` with `VW0` / `VD4` /
|
||||
`V0.0` rows and a `-c S7200Smart` worked example; updates
|
||||
`docs/drivers/S7-Test-Fixture.md` §"What it does NOT cover" item 4 to
|
||||
note S7-200 / LOGO! parser coverage now exists at unit level; adds
|
||||
unit-only `S7AddressParserTests` cases — no Snap7 fixture change
|
||||
(server.py already exposes DB1, which is where V-memory aliases land);
|
||||
no `scripts/e2e/test-s7.ps1` change required (live-LOGO! testing is
|
||||
documented as field-only).
|
||||
|
||||
### Phase 2 — Performance (multi-tag PDU packing + block coalescing)
|
||||
|
||||
#### PR-S7-B1 — Multi-variable PDU packing
|
||||
|
||||
Closes gap #3. `ReadAsync(IReadOnlyList<string>)` currently issues one
|
||||
`plc.ReadAsync` per tag inside the semaphore — N PDUs for N tags.
|
||||
|
||||
- **Files**: `S7Driver.cs` (replace per-tag loop with a packer that builds a
|
||||
list of `S7.Net.Types.DataItem`, calls `plc.ReadMultipleVarsAsync`, then
|
||||
fans the results back to the per-tag decoder). Keep the existing per-tag
|
||||
decode switch — only the wire fetch becomes batched.
|
||||
- **Tests**: integration test that subscribes to 100 tags and asserts the
|
||||
packet count seen by the Snap7 server is 1 (or N / packing-budget) rather
|
||||
than 100. Unit-level test covers packer chunking when the negotiated PDU
|
||||
size won't fit all items.
|
||||
- **Risks**: `ReadMultipleVarsAsync` errors are per-item; we must surface
|
||||
per-tag StatusCodes correctly rather than failing the whole batch on one
|
||||
bad tag. Packing budget = `negotiatedPduSize - 18 (header) - per_item(12)`,
|
||||
conservatively cap at 19 items per PDU on a 240-byte PDU.
|
||||
- **Effort**: L (5-6 days incl. the per-item-error fan-out semantics).
|
||||
- **Deps**: Phase 1 PRs do not block this — but conflicts in `S7Driver.cs`
|
||||
are likely, so land Phase 1 first.
|
||||
- **Docs / fixture / e2e**: adds a "Performance — multi-variable PDU
|
||||
packing" subsection to `docs/v2/s7.md` describing
|
||||
`ReadMultipleVarsAsync`, the negotiated-PDU packing budget formula
|
||||
(`pdu - 18 - 12·N`), the 19-items-per-240-byte-PDU rule of thumb, and
|
||||
the per-item-error semantics; no `docs/Driver.S7.Cli.md` change (CLI
|
||||
is single-tag); no Snap7-server seed change required (existing seeds
|
||||
cover the wire path); adds
|
||||
`S7MultiVarPduPackingTests` to the unit suite (planner chunking when
|
||||
items don't fit) + a 100-tag perf integration test
|
||||
`Driver_packs_100_tags_into_minimum_pdus` that asserts request-count
|
||||
reduction; no `scripts/e2e/test-s7.ps1` change required.
|
||||
|
||||
#### PR-S7-B2 — Block-read coalescing for contiguous DBs
|
||||
|
||||
Closes gap #22. Reading `DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` should issue one
|
||||
6-byte byte-range read against DB1 starting at offset 0, sliced client-side.
|
||||
|
||||
- **Files**: `S7Driver.cs` adds a planner pass: group same-DB tags by
|
||||
contiguous byte ranges (gap-merge threshold = configurable, default 16
|
||||
bytes; over-fetching 16 bytes is cheaper than one extra PDU). Merged ranges
|
||||
become a single `Plc.ReadBytes` call; the result is sliced per-tag.
|
||||
- **Tests**: unit tests for the merge planner (input list → expected ranges);
|
||||
integration test with 50 contiguous DB words proves wire-level reduction.
|
||||
- **Risks**: STRINGs / arrays should opt out of merging because the per-tag
|
||||
byte size is variable. Add an "opaque-size" flag so the planner skips them.
|
||||
- **Effort**: M.
|
||||
- **Deps**: PR-S7-B1 (the multi-var packer). The two interact: the planner
|
||||
emits sum-reads, then the packer puts multiple sum-reads on one PDU.
|
||||
- **Docs / fixture / e2e**: extends the §"Performance" section in
|
||||
`docs/v2/s7.md` with a "Block-read coalescing" subsection — the
|
||||
default 16-byte gap-merge threshold, the opaque-size opt-out for
|
||||
STRINGs / arrays, and operator guidance for tuning the threshold per
|
||||
DB; no CLI doc change; no Snap7-server seed change (existing
|
||||
contiguous DB1 seeds — DBW0 / DBW10 / DBD20 — already exercise
|
||||
contiguous-merge); adds
|
||||
`S7BlockCoalescingPlannerTests` (unit) covering the merge planner +
|
||||
opaque opt-out; adds a 50-contiguous-DBW integration test
|
||||
`Driver_coalesces_contiguous_DBWs_into_single_byte_range_read` that
|
||||
asserts wire-level reduction; no `scripts/e2e/test-s7.ps1` change.
|
||||
|
||||
### Phase 3 — Operability
|
||||
|
||||
#### PR-S7-C1 — PDU size negotiation surfaced
|
||||
|
||||
Closes gap #2. S7netplus's `Plc` instance exposes the negotiated PDU size after
|
||||
`OpenAsync` via `Plc.MaxPDUSize`.
|
||||
|
||||
- **Files**: `S7Driver.cs` (read `Plc.MaxPDUSize` after open, store on
|
||||
`_health`; expose via `GetHealth().Diagnostics["NegotiatedPduSize"]` —
|
||||
this requires adding a `Diagnostics` dictionary to `DriverHealth`, which
|
||||
is a Core change). Operator-visible via the Admin UI driver-diagnostics
|
||||
panel that already renders Modbus diagnostic stats.
|
||||
- **Tests**: integration test asserts the value is non-zero after init.
|
||||
- **Risks**: `DriverHealth` extension must be backward-compatible — existing
|
||||
drivers should still compile against the unchanged record. Make the new
|
||||
property nullable with a default of `null`.
|
||||
- **Effort**: S.
|
||||
- **Deps**: Core `DriverHealth` shape change (single PR coordinated with
|
||||
the Modbus diagnostic surface).
|
||||
- **Docs / fixture / e2e**: adds a "Diagnostics surfacing" subsection to
|
||||
`docs/v2/s7.md` documenting the `Diagnostics["NegotiatedPduSize"]`
|
||||
surface + how it renders in the Admin UI driver-diagnostics panel;
|
||||
no CLI doc change (CLI doesn't expose diagnostics); updates
|
||||
`docs/drivers/S7-Test-Fixture.md` §"What it actually covers" with a
|
||||
"negotiated PDU size surfaces in driver health" line; no Snap7
|
||||
seed-type change (snap7's PDU negotiation is fixed at 240 bytes —
|
||||
document the fixture's negotiated size in the README); adds
|
||||
`Driver_exposes_negotiated_pdu_size_post_init` smoke test asserting
|
||||
the value is non-zero; no `scripts/e2e/test-s7.ps1` change.
|
||||
|
||||
#### PR-S7-C2 — TSAP / Connection Type selector
|
||||
|
||||
Closes gap #4. S7netplus picks PG-class TSAPs by default; hardened CPUs may
|
||||
require OP / S7-Basic / Other.
|
||||
|
||||
- **Files**: `S7DriverOptions.cs` (new `TsapMode` enum: `Auto` / `Pg` / `Op` /
|
||||
`S7Basic` / `Other`; `Auto` preserves current behavior. Optional
|
||||
`LocalTsap` / `RemoteTsap` `ushort?` for explicit override). `S7Driver.cs`
|
||||
branches on the mode to pick the S7netplus `Plc(CpuType, ...)` constructor
|
||||
vs the `Plc(string ip, byte rack, byte slot, ushort localTsap, ushort remoteTsap)`
|
||||
raw-TSAP overload. Document the raw-TSAP table in `docs/v2/s7.md`.
|
||||
- **Tests**: unit test on the mode → TSAP-byte mapping; live-firmware test
|
||||
documented but only runnable against the dev-box S7-1500 lab rig.
|
||||
- **Risks**: wrong TSAP causes connection refused at handshake — same failure
|
||||
shape as wrong slot. Document the mapping prominently.
|
||||
- **Effort**: M.
|
||||
- **Deps**: none.
|
||||
- **Docs / fixture / e2e**: adds a "TSAP / Connection Type" section to
|
||||
`docs/v2/s7.md` covering the `TsapMode` enum, the raw-TSAP table
|
||||
(PG = 0x0100/0x0102, OP = 0x0200/0x0202, S7-Basic = 0x0300/0x0302,
|
||||
Other = caller-supplied), and the hardened-CPU motivation; adds
|
||||
`--tsap-mode` and `--local-tsap` / `--remote-tsap` flags to
|
||||
`docs/Driver.S7.Cli.md`'s common-flags table with a worked example
|
||||
hitting an OP-class TSAP; no Snap7 seed change (snap7 accepts any
|
||||
TSAP from the CLI, so the unit-level mapping test is sufficient); no
|
||||
smoke test change (live-firmware-only); no `scripts/e2e/test-s7.ps1`
|
||||
change.
|
||||
|
||||
#### PR-S7-C3 — Per-tag scan group / publish rate
|
||||
|
||||
Closes gap #20. `SubscribeAsync` takes one publishing interval for the whole
|
||||
list; mixed 100 ms / 1 s / 10 s tags need three subscribe calls today.
|
||||
|
||||
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with optional
|
||||
`ScanGroup` string). `S7Driver.cs` (`SubscribeAsync` partitions the input
|
||||
list into one poll loop per distinct interval; `PollGroupEngine`-style
|
||||
internal group, but driver-local — same engine the TwinCAT driver uses).
|
||||
- **Tests**: unit test with three tags at three rates asserts three independent
|
||||
poll-tick streams; integration test asserts no group starves the others.
|
||||
- **Risks**: the `_gate` semaphore still serializes — three poll loops can
|
||||
contend. Document the contention as part of the "1 connection / 1 mailbox"
|
||||
invariant; if it bites, follow-up adds a fairness queue.
|
||||
- **Effort**: M.
|
||||
- **Deps**: none.
|
||||
- **Docs / fixture / e2e**: adds a "Per-tag scan groups" subsection to
|
||||
`docs/v2/s7.md` documenting `S7TagDefinition.ScanGroup`, the multi-rate
|
||||
partitioning semantics, and the `_gate` contention caveat; no CLI doc
|
||||
change (CLI is single-tag); no Snap7 seed change required (existing
|
||||
scalar seeds suffice); adds `S7ScanGroupPartitioningTests` (unit) +
|
||||
`Driver_three_scan_groups_publish_independently` smoke test that
|
||||
subscribes 3 tags at 100 ms / 1 s / 10 s rates and asserts
|
||||
independent tick streams; no `scripts/e2e/test-s7.ps1` change
|
||||
(subscribe assertion already covers the polling path).
|
||||
|
||||
#### PR-S7-C4 — Deadband / on-change with thresholds
|
||||
|
||||
Closes gap #21. `PollOnceAsync` currently does `!Equals(prev, current)` only —
|
||||
no analog deadband.
|
||||
|
||||
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with
|
||||
`DeadbandAbsolute double?` and `DeadbandPercent double?`). `S7Driver.cs`
|
||||
(`PollOnceAsync` evaluates per-tag deadband for numeric types; non-numeric
|
||||
types fall through to exact equality).
|
||||
- **Tests**: unit tests for absolute and percent deadbands at edge cases
|
||||
(NaN, ±Infinity, sign flip, near-zero percent).
|
||||
- **Risks**: percent deadband against a zero baseline diverges; document and
|
||||
fall back to absolute when |baseline| < 1e-6.
|
||||
- **Effort**: S.
|
||||
- **Deps**: PR-S7-C3 helpful but not required.
|
||||
- **Docs / fixture / e2e**: adds a "Deadband / on-change" subsection to
|
||||
`docs/v2/s7.md` documenting `DeadbandAbsolute` / `DeadbandPercent` per
|
||||
tag, NaN / ±Infinity / sign-flip / near-zero-percent edge cases, and
|
||||
the |baseline| < 1e-6 fallback; no CLI doc change (CLI's `subscribe`
|
||||
already polls on change); no Snap7 seed change; adds
|
||||
`S7DeadbandTests` (unit) covering all edge cases — no integration test
|
||||
required since deadband is pre-publish filtering inside the polling
|
||||
loop; no `scripts/e2e/test-s7.ps1` change.
|
||||
|
||||
#### PR-S7-C5 — Pre-flight PUT/GET enablement test
|
||||
|
||||
Closes gap #24. We currently surface `BadDeviceFailure` only at first read.
|
||||
Add a pre-flight check during `InitializeAsync` (after `OpenAsync`) that issues
|
||||
one trivial read (`MW0` or the configured `Probe.ProbeAddress`) and surfaces
|
||||
the dedicated diagnostic message before declaring `DriverState.Healthy`.
|
||||
|
||||
- **Files**: `S7Driver.cs` (`InitializeAsync` adds the probe read; on
|
||||
`S7.Net.PlcException` with the PUT/GET-disabled error code, throw a
|
||||
typed `S7PutGetDisabledException` with a configuration-fix hint).
|
||||
- **Tests**: integration test toggles a Snap7 simulator quirk that mimics
|
||||
the PUT/GET-disabled response (Snap7 doesn't model this; gate the test
|
||||
on a `--with-real-plc` opt-in or document as live-firmware-only).
|
||||
- **Risks**: pre-flight against a real `Probe.ProbeAddress` requires the
|
||||
address to exist in the PLC; document that the default `MW0` is fine for
|
||||
most installs but allow `null` / "skip" for sites that haven't wired one.
|
||||
- **Effort**: S.
|
||||
- **Deps**: none.
|
||||
- **Docs / fixture / e2e**: extends the "PUT/GET must be enabled" section
|
||||
of `docs/Driver.S7.Cli.md` with the new typed
|
||||
`S7PutGetDisabledException` message + the "skip pre-flight" knob;
|
||||
adds the same content as a "Pre-flight PUT/GET enablement" subsection
|
||||
in `docs/v2/s7.md`; no Snap7 seed change (snap7 doesn't model
|
||||
PUT/GET-disabled — the test for the success path uses the existing
|
||||
MW0 seed); adds `Driver_preflight_passes_when_probe_address_seeded`
|
||||
smoke test; documents the live-firmware test as gated on a
|
||||
`--with-real-plc` opt-in flag in `docs/drivers/S7-Test-Fixture.md`
|
||||
§"Follow-up candidates"; no `scripts/e2e/test-s7.ps1` change (probe
|
||||
test already runs first).
|
||||
|
||||
### Phase 4 — Workflow (symbol import + UDTs + instance DBs)
|
||||
|
||||
#### PR-S7-D1 — Symbol-table / TIA Portal export browse
|
||||
|
||||
Closes gap #5. Operators currently hand-edit `S7TagDefinition` JSON. TIA Portal
|
||||
exports symbols as **`.s7p` archive → External tags → CSV / SDF**. The lighter
|
||||
target is the CSV format used by the "Generate source from blocks" exporter.
|
||||
|
||||
- **Files**: new `src/ZB.MOM.WW.OtOpcUa.Driver.S7/SymbolImport/` directory:
|
||||
- `TiaCsvImporter.cs` — parses TIA Portal "Show all tags" CSV (`Name`,
|
||||
`Address`, `Data type`, `Comment`, `Visible in HMI`). Output: list of
|
||||
`S7TagDefinition`.
|
||||
- `AwlImporter.cs` — best-effort AWL `VAR_GLOBAL` / `DATA_BLOCK` parser
|
||||
for legacy STEP 7 Classic projects.
|
||||
- **Files (Admin UI)**: a "Import S7 symbols" button on the Driver Tags tab
|
||||
that POSTs the file to a new `POST /api/drivers/{id}/import-s7-symbols`
|
||||
endpoint and reports the diff.
|
||||
- **Tests**: unit tests with golden-input CSV / AWL fixtures; round-trip
|
||||
test that imports → produces tags → reads against simulator.
|
||||
- **Risks**: TIA Portal CSV is locale-dependent (decimal-comma in DE locale).
|
||||
Detect from the header row and accept both. UDT-typed symbols import as
|
||||
a placeholder until PR-S7-D2.
|
||||
- **Effort**: L (5-7 days incl. the Admin UI flow).
|
||||
- **Deps**: see Open Question (c) — confirm CSV+AWL is the right scope, or
|
||||
whether `.s7p` / `.zip` archive parsing is required.
|
||||
- **Docs / fixture / e2e**: adds new doc
|
||||
`docs/drivers/S7-TIA-Import.md` documenting the supported TIA Portal
|
||||
CSV format (column names, locale-comma detection, UDT-typed
|
||||
placeholders) and the AWL `VAR_GLOBAL` / `DATA_BLOCK` parser scope;
|
||||
cross-links it from `docs/v2/s7.md`'s new "Symbol import" section
|
||||
and from `docs/Driver.S7.Cli.md` with a future `import` subcommand
|
||||
hook; adds golden-input fixtures
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Fixtures/sample_tia_export.csv`,
|
||||
`sample_tia_export_de_locale.csv`, and `sample_step7_classic.awl`;
|
||||
no Snap7 seed change required (existing DB1 seeds support
|
||||
the import-then-read round-trip); adds `TiaCsvImporterTests` and
|
||||
`AwlImporterTests` (unit) + `Driver_imports_csv_then_reads_seeded_tags`
|
||||
integration test that imports the sample CSV → reads via Snap7;
|
||||
no `scripts/e2e/test-s7.ps1` change (Admin-UI flow has its own
|
||||
end-to-end coverage in the Admin UI test suite).
|
||||
|
||||
#### PR-S7-D2 — UDT / STRUCT / nested-DB handling
|
||||
|
||||
Closes gap #6. Today's tag map is flat scalar-only; UDT-typed DBs are
|
||||
unusable without hand-flattening every member.
|
||||
|
||||
- **Files**: `S7DriverOptions.cs` (extend `S7TagDefinition` with `UdtName string?`;
|
||||
alongside, a new `IReadOnlyList<S7UdtDefinition> Udts` on the options that
|
||||
declares the layout: name, ordered members `(Name, Offset, S7DataType, ArrayDim?)`).
|
||||
`S7Driver.cs` fans a UDT-typed tag into per-member sub-tags at `InitializeAsync`,
|
||||
so the read/write path stays scalar-only.
|
||||
- **Tests**: unit tests for fan-out with nested UDTs (UDT-of-UDT); integration
|
||||
test with a Snap7 DB seeded as a UDT-shape byte array proves the fan-out
|
||||
decodes correctly.
|
||||
- **Risks**: UDT-of-UDT arbitrary nesting depth — cap at 4 levels and reject
|
||||
deeper with a clear error. Optimized DBs would let TIA reorder members,
|
||||
re-introducing gap #1; document that user-defined UDTs require "Optimized
|
||||
block access" off, same as the general DB rule.
|
||||
- **Effort**: L (1-2 weeks).
|
||||
- **Deps**: PR-S7-D1 (symbol importer drops UDT-typed entries with a
|
||||
placeholder; D2 makes those usable).
|
||||
- **Docs / fixture / e2e**: adds a "UDT / STRUCT support" section to
|
||||
`docs/v2/s7.md` documenting `S7UdtDefinition`, the fan-out
|
||||
semantics, the 4-level nesting cap, and the "Optimized block access
|
||||
must be off" prerequisite; extends `docs/drivers/S7-TIA-Import.md`
|
||||
(created in PR-S7-D1) with a UDT-typed-entry section showing how
|
||||
the importer + `Udts` declaration cooperate; updates
|
||||
`docs/drivers/S7-Test-Fixture.md` §"What it does NOT cover" item 5 to
|
||||
remove "UDT fan-out"; extends `Docker/server.py` with a
|
||||
`udt_layout` meta-seed-type that lays out per-member offsets within
|
||||
a DB byte range; seeds a `DB1.MyUdt[400]` (e.g. Real + Int + Bool)
|
||||
in `Docker/profiles/s7_1500.json`; adds `S7UdtFanOutTests` (unit) +
|
||||
`Driver_fans_out_udt_into_member_tags` integration test covering a
|
||||
nested-UDT case; adds a UDT-member round-trip assertion to
|
||||
`scripts/e2e/test-s7.ps1`.
|
||||
|
||||
#### PR-S7-D3 — Instance-DB / FB parameter access
|
||||
|
||||
Closes gap #10. Multi-instance FBs are addressed symbolically (`MyFB_Instance.MyParam`)
|
||||
with no fixed absolute DB byte offset visible without a TIA project export.
|
||||
|
||||
- **Files**: extends PR-S7-D1's importer to recognize "instance DB" entries
|
||||
(TIA export shows them with a different "DB type" column value); the
|
||||
importer translates `MyFB_Instance.MyParam` to the resolved
|
||||
`DBn.DBW_offset` based on the FB's interface declaration in the export.
|
||||
- **Tests**: golden-input test with an FB-instance DB export; resolved
|
||||
addresses match Siemens reference.
|
||||
- **Risks**: when the FB interface changes (TIA "online change"), instance-DB
|
||||
layouts shift. Document that re-import is required after any FB-interface
|
||||
edit. Eventually surface this as a startup warning when the symbol-table
|
||||
hash differs from the imported snapshot — out of scope for this PR.
|
||||
- **Effort**: M.
|
||||
- **Deps**: PR-S7-D1, PR-S7-D2.
|
||||
- **Docs / fixture / e2e**: extends `docs/drivers/S7-TIA-Import.md` with
|
||||
an "Instance DBs / FB parameters" section covering the importer's
|
||||
`MyFB_Instance.MyParam` → `DBn.DBW_offset` resolution, the "DB type"
|
||||
column convention, and the "re-import on FB-interface edit" caveat;
|
||||
adds the same caveat as a paragraph in `docs/v2/s7.md`'s "UDT /
|
||||
STRUCT" section; adds a golden-input fixture
|
||||
`Fixtures/sample_tia_export_with_fb_instance.csv` to the integration
|
||||
tests; no Snap7 seed change required (resolved addresses land in DB1
|
||||
which the existing seeds back); adds
|
||||
`InstanceDbResolverTests` (unit) +
|
||||
`Driver_resolves_fb_instance_then_reads_seeded_member` integration
|
||||
test; no `scripts/e2e/test-s7.ps1` change (FB-instance lookup is an
|
||||
import-time concern).
|
||||
|
||||
### Phase 5 — Diagnostics & security
|
||||
|
||||
#### PR-S7-E1 — CPU diagnostic buffer / SZL reads
|
||||
|
||||
Closes gap #11. SZL (System Status List) IDs surface CPU type, firmware
|
||||
version, cycle-time min/avg/max, and the diagnostic-buffer entries.
|
||||
|
||||
- **Files**: `S7Driver.cs` exposes a small set of "system tags" alongside
|
||||
`Tags` — virtual addresses prefixed `@System.` that the read path
|
||||
recognizes and dispatches to S7netplus's `ReadSzlAsync` (or, if not
|
||||
exposed, a raw `Plc.ReadBytes` against the SZL-via-S7comm sub-protocol):
|
||||
- `@System.CpuType`, `@System.Firmware`, `@System.OrderNo` — SZL 0x0011
|
||||
- `@System.CycleMs.Min` / `.Max` / `.Avg` — SZL 0x0132 / 0x0432
|
||||
- `@System.DiagBuffer[0..N]` — SZL 0x00A0 ring-buffer entries
|
||||
- **Files (discovery)**: `DiscoverAsync` adds a `Diagnostics/` subfolder
|
||||
with the system-tag set when `S7DriverOptions.ExposeSystemTags = true`.
|
||||
- **Tests**: unit tests for the SZL response parser (golden bytes); live-
|
||||
firmware test against the dev-box S7-1500.
|
||||
- **Risks**: S7netplus's SZL surface is incomplete; may need a raw
|
||||
`Plc.ReadBytes` against `0x84` register or a small SZL-PDU helper.
|
||||
- **Effort**: M-L.
|
||||
- **Deps**: PR-S7-C1 (`DriverHealth.Diagnostics` dictionary already there).
|
||||
- **Docs / fixture / e2e**: adds a "CPU diagnostics (SZL)" section to
|
||||
`docs/v2/s7.md` listing the exposed `@System.*` virtual addresses, the
|
||||
underlying SZL IDs, and the `ExposeSystemTags` opt-in; extends
|
||||
`docs/Driver.S7.Cli.md` with a worked `read -a @System.CpuType` example
|
||||
in the cookbook; updates `docs/drivers/S7-Test-Fixture.md` §"What it
|
||||
does NOT cover" with a note that snap7 does not implement SZL — golden-
|
||||
byte unit tests cover the parser, live SZL is gated on a real S7-1500;
|
||||
no Snap7 seed change (snap7 returns a fixed handshake banner that the
|
||||
test checks for "SZL not supported on simulator" branch); adds
|
||||
`S7SzlParserTests` (unit) with golden bytes; documents the live SZL
|
||||
test in `docs/drivers/S7-Test-Fixture.md` §"Follow-up candidates"; no
|
||||
`scripts/e2e/test-s7.ps1` change.
|
||||
|
||||
#### PR-S7-E2 — PLC password / protection-level handling
|
||||
|
||||
Closes gap #14. S7-300/400 protection levels 1-3 and S7-1200/1500 connection
|
||||
mechanisms can require a password on connect.
|
||||
|
||||
- **Files**: `S7DriverOptions.cs` (new `Password string?` and `ProtectionLevel`
|
||||
enum). `S7Driver.cs` calls S7netplus's `SetPassword` (if the API surfaces it
|
||||
— newer S7netplus versions ship `Plc.SendPassword(string)`; if not, raw-PDU
|
||||
fallback per Siemens "Communication Function Manual" §5.2).
|
||||
- **Tests**: live-firmware-gated; password-tier failure modes don't reproduce
|
||||
in Snap7. Unit-level coverage for the options-binding shape only.
|
||||
- **Risks**: S7netplus may not expose password auth — fallback is to call into
|
||||
the lower-level `S7.Net.S7Protocol` types or to fork. Land the options
|
||||
surface unconditionally, gate the wire path on library support, document
|
||||
the limitation if the library doesn't oblige.
|
||||
- **Effort**: M (S if S7netplus ships it; L if we need a fallback path).
|
||||
- **Deps**: none.
|
||||
- **Docs / fixture / e2e**: adds a "PLC password / protection levels"
|
||||
section to `docs/v2/s7.md` documenting the `Password` /
|
||||
`ProtectionLevel` options + the S7-300/400 levels 1-3 vs S7-1200/1500
|
||||
connection-mechanism semantics + the "limitation if S7netplus
|
||||
doesn't ship `SendPassword`" note; adds a `--password` flag to
|
||||
`docs/Driver.S7.Cli.md`'s common-flags table with a hardened-CPU
|
||||
worked example; updates `docs/drivers/S7-Test-Fixture.md` §"What it
|
||||
does NOT cover" with a "password / protection levels not modelled by
|
||||
snap7" note; no Snap7 seed change (snap7 doesn't enforce protection
|
||||
levels); adds options-binding unit tests only — no integration test
|
||||
(live-firmware-only); no `scripts/e2e/test-s7.ps1` change.
|
||||
|
||||
### Phase 6 — S7-1500 Optimized DB / Symbolic addressing (decision PR)
|
||||
|
||||
#### PR-S7-F — Optimized DB / S7Plus
|
||||
|
||||
Closes gap #1. **This is an architectural decision PR, not a code PR.**
|
||||
|
||||
S7netplus speaks classic S7comm only. Optimized DBs on S7-1500 (default for
|
||||
new TIA projects) reorder fields and have no fixed byte offsets — absolute
|
||||
`DB1.DBW0` reads return `BadDeviceFailure`. Three tracks:
|
||||
|
||||
1. **Document the constraint and stay on S7netplus.** Operators must uncheck
|
||||
"Optimized block access" in TIA Portal for any DB the driver reads. This
|
||||
is what the test fixture already documents. Effort: S (docs only).
|
||||
2. **Migrate to a library that supports S7Plus.**
|
||||
- **Snap7 v2 / `Snap7Net`** — C-library wrapper, supports classic S7comm
|
||||
only (same limitation as S7netplus). Not a fix.
|
||||
- **Sharp7 fork** — community fork of Snap7 with **partial** S7-1200/1500
|
||||
PUT/GET semantics. Still classic S7comm.
|
||||
- **Custom S7Plus implementation** — Wireshark dissector exists; reverse
|
||||
engineering is substantial. Effort: ≥ 4 weeks; ongoing protocol-version
|
||||
maintenance. Risk: Siemens has not published S7Plus.
|
||||
3. **Embed an OPC UA → OPC UA bridge to the S7-1500's onboard OPC UA server.**
|
||||
The S7-1500 V2.5+ exposes its own OPC UA server with full symbolic access.
|
||||
Our `OPC UA Client driver` (already shipping per memory) could read the
|
||||
target CPU's OPC UA server and re-publish — sidesteps S7Plus entirely.
|
||||
Effort: S; semantics: requires the customer to license Siemens OPC UA
|
||||
on the CPU. Most modern S7-1500 deployments already license it.
|
||||
|
||||
**Recommendation**: ship Track 1 docs immediately (closes the operator
|
||||
expectation gap) and Track 3 as the Optimized-DB workflow path (re-uses
|
||||
existing OPC UA Client driver). Track 2 (S7Plus reverse-engineering) is
|
||||
out of scope unless a customer pays for it.
|
||||
|
||||
- **Files**: `docs/v2/s7.md` (Optimized DB section + how to disable),
|
||||
`docs/featuregaps.md` row #1 updated to reflect the Track 1+3 decision.
|
||||
- **Tests**: live-firmware test against the dev-box S7-1500 with optimized
|
||||
block access toggled both ways, asserting `BadDeviceFailure` vs
|
||||
successful read.
|
||||
- **Risks**: Track 3's OPC-UA-Client-bridging needs Admin UI plumbing to
|
||||
configure; that's a larger workstream tracked separately.
|
||||
- **Effort**: S (docs + decision); L if Track 2 is taken.
|
||||
- **Deps**: Open Question (a) below.
|
||||
- **Docs / fixture / e2e**: rewrites `docs/v2/s7.md` to land a
|
||||
prominent "Optimized DB constraint" section at the top — explicitly
|
||||
documents the S7-1200 V4.0+ / S7-1500 default, the
|
||||
`BadDeviceFailure` shape on absolute `DB1.DBW0` reads against an
|
||||
optimized DB, the "Uncheck Optimized block access in TIA Portal"
|
||||
fix, and the recommended **bridge-via-OpcUaClient** pattern with a
|
||||
worked example (Siemens S7-1500 V2.5+ onboard OPC UA server →
|
||||
`OpcUaClient` driver → re-publish on the OtOpcUa server's address
|
||||
space); updates `docs/featuregaps.md` row #1 to reflect the
|
||||
Track 1+3 decision; updates the "Optimized-DB" line of
|
||||
`docs/drivers/S7-Test-Fixture.md` §"What it does NOT cover" item 4
|
||||
to point at the new doc; no CLI doc change (CLI is a probe tool, not
|
||||
the bridging path); no Snap7 fixture change (snap7 has no Optimized-
|
||||
DB mode); the live-firmware test toggling Optimized block access on
|
||||
/ off is recorded as a manual checklist in
|
||||
`docs/drivers/S7-Test-Fixture.md` §"Follow-up candidates" and gated
|
||||
behind `--with-real-plc`; if Track 2 is taken later, this PR's doc
|
||||
surface becomes the migration baseline; no `scripts/e2e/test-s7.ps1`
|
||||
change.
|
||||
|
||||
---
|
||||
|
||||
## Documentation, fixture, and e2e impact
|
||||
|
||||
Consolidated view of every per-PR `Docs / fixture / e2e` line above, so a
|
||||
reviewer can see the cross-cutting churn at a glance and so the doc /
|
||||
fixture / e2e maintainers can sequence their work alongside the code PRs.
|
||||
|
||||
### User-facing documentation churn
|
||||
|
||||
| PR | `docs/v2/s7.md` | `docs/Driver.S7.Cli.md` | `docs/drivers/S7-Test-Fixture.md` | New / cross-cut docs |
|
||||
|----|-----------------|-------------------------|------------------------------------|----------------------|
|
||||
| PR-S7-A1 (LInt/ULInt/LReal/LWord) | extend type-mapping table | new sizes in cookbook | remove "no 64-bit types" | — |
|
||||
| PR-S7-A2 (STRING/WSTRING/CHAR/WCHAR) | string layout subsection | `--type WString` / `--string-length` | list new types | — |
|
||||
| PR-S7-A3 (DTL/DT/S5TIME/TIME/TOD/DATE) | "Date / time types" subsection | datetime cookbook entries | list new types | — |
|
||||
| PR-S7-A4 (arrays) | "Array tags (ValueRank=1)" subsection | `--array-count` flag + examples | list array round-trips | — |
|
||||
| PR-S7-A5 (V-memory) | "LOGO! 8 / S7-200 V-memory" subsection | grammar table + S7200Smart example | parser coverage note | — |
|
||||
| PR-S7-B1 (PDU packing) | "Performance — multi-variable PDU packing" subsection | — | — | — |
|
||||
| PR-S7-B2 (block coalescing) | "Block-read coalescing" subsection | — | — | — |
|
||||
| PR-S7-C1 (negotiated PDU diag) | "Diagnostics surfacing" subsection | — | "negotiated PDU size" line | — |
|
||||
| PR-S7-C2 (TSAP) | "TSAP / Connection Type" section | `--tsap-mode` / `--local-tsap` / `--remote-tsap` flags | — | — |
|
||||
| PR-S7-C3 (scan groups) | "Per-tag scan groups" subsection | — | — | — |
|
||||
| PR-S7-C4 (deadband) | "Deadband / on-change" subsection | — | — | — |
|
||||
| PR-S7-C5 (PUT/GET pre-flight) | "Pre-flight PUT/GET enablement" subsection | extend "PUT/GET must be enabled" | mark live-firmware test | — |
|
||||
| PR-S7-D1 (TIA CSV / AWL import) | "Symbol import" cross-link | future `import` subcommand stub | — | **new `docs/drivers/S7-TIA-Import.md`** |
|
||||
| PR-S7-D2 (UDT / STRUCT) | "UDT / STRUCT support" section | — | remove "UDT fan-out" | extend `S7-TIA-Import.md` |
|
||||
| PR-S7-D3 (instance DB) | re-import-on-FB-edit caveat | — | — | extend `S7-TIA-Import.md` |
|
||||
| PR-S7-E1 (SZL diagnostics) | "CPU diagnostics (SZL)" section | `read -a @System.CpuType` example | "SZL not modelled by snap7" + Follow-up | — |
|
||||
| PR-S7-E2 (PLC password) | "PLC password / protection levels" section | `--password` flag | "password not modelled by snap7" | — |
|
||||
| PR-S7-F (Optimized DB / S7Plus) | top-level "Optimized DB constraint" + bridge-via-OpcUaClient worked example | — | point §"What it does NOT cover" at new doc | also updates `docs/featuregaps.md` row #1 |
|
||||
|
||||
### Snap7-server fixture seed-type additions per PR
|
||||
|
||||
The snap7 simulator at `localhost:1102` (driven by
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/server.py` +
|
||||
`Docker/profiles/s7_1500.json`) has a `seed_buffer` pump with a fixed type
|
||||
set — `u8 / i8 / u16 / i16 / u32 / i32 / f32 / bool / ascii`. New PRs need
|
||||
new seed-type cases in `server.py`, new offsets in `s7_1500.json`, and
|
||||
matching constants in `S7_1500Profile.cs`. The table below names the
|
||||
delta for each Build-Yes PR:
|
||||
|
||||
| PR | New `server.py` seed types | New `s7_1500.json` seed offsets | `S7_1500Profile.cs` additions |
|
||||
|----|----------------------------|----------------------------------|-------------------------------|
|
||||
| PR-S7-A1 | `i64`, `u64`, `f64` | `DB1.DBL40` (i64), `DB1.DBL48` (f64), `DB1.DBL56` (u64) | `SmokeI64Tag` / `SmokeU64Tag` / `SmokeF64Tag` |
|
||||
| PR-S7-A2 | `wstring`, `char`, `wchar` (existing `ascii` covers STRING) | `DB1.WSTRING[256]`, `DB1.CHAR[300]` | `SmokeWStringTag` / `SmokeCharTag` |
|
||||
| PR-S7-A3 | `dtl`, `dt`, `s5time`, `time`, `tod`, `date` (golden-byte vectors in comments) | `DB1.DTL[260]`, `DB1.DT[272]`, `DB1.S5TIME[280]`, `DB1.TIME[284]`, `DB1.TOD[288]`, `DB1.DATE[292]` | `SmokeDtl` / `SmokeDt` / `SmokeS5Time` / `SmokeTime` / `SmokeTod` / `SmokeDate` |
|
||||
| PR-S7-A4 | `array` meta-seed (inner-type + count) | `DB1.ArrayInt[300]` 10×Int, `DB1.ArrayReal[320]` 10×Real | `ArrayInt10Tag` / `ArrayReal10Tag` |
|
||||
| PR-S7-A5 | none (V-memory aliases land in DB1, which `server.py` already exposes) | none | unit-only — no profile change |
|
||||
| PR-S7-B1 | none | none (existing scalar seeds suffice for packing) | none — perf integration test reuses scalar tags |
|
||||
| PR-S7-B2 | none | none (existing contiguous DBW0 / DBW10 / DBD20 already test merge) | none |
|
||||
| PR-S7-C1 | none | none | none |
|
||||
| PR-S7-C2 | none (snap7 accepts any TSAP) | none | none |
|
||||
| PR-S7-C3 | none | none | none |
|
||||
| PR-S7-C4 | none | none | none |
|
||||
| PR-S7-C5 | none (existing `MK0` MW0 seed covers success path) | none | none |
|
||||
| PR-S7-D1 | none (CSV import lands tags pointing at existing seeds) | none | possibly add fixture-pointer constants |
|
||||
| PR-S7-D2 | `udt_layout` meta-seed (per-member offsets) | `DB1.MyUdt[400]` (Real + Int + Bool layout) | `MyUdtTag` + member tags |
|
||||
| PR-S7-D3 | none (resolved addresses land in DB1) | none | none |
|
||||
| PR-S7-E1 | none — snap7 doesn't model SZL; unit-level golden bytes cover the parser | none | none |
|
||||
| PR-S7-E2 | none — snap7 doesn't enforce protection levels; options-binding unit tests only | none | none |
|
||||
| PR-S7-F | none — snap7 has no Optimized-DB mode; live-firmware checklist instead | none | none |
|
||||
|
||||
### E2E `scripts/e2e/test-s7.ps1` impact
|
||||
|
||||
`scripts/e2e/test-s7.ps1` runs the five-assertion CLI loopback (probe /
|
||||
driver-loopback / forward-bridge / reverse-bridge / subscribe-sees-change)
|
||||
against `DB1.DBW0` Int16. Build-Yes PRs that add CLI surface get a
|
||||
matching loopback assertion; PRs that touch only internals or admin-UI
|
||||
flows do not.
|
||||
|
||||
| PR | E2E script change |
|
||||
|----|-------------------|
|
||||
| PR-S7-A1 | add LInt loopback assertion (write 0x7FFFFFFFFFFFFFFF, read back) |
|
||||
| PR-S7-A2 | add string round-trip assertion |
|
||||
| PR-S7-A3 | none (CLI cookbook covers manual surface) |
|
||||
| PR-S7-A4 | add array round-trip assertion |
|
||||
| PR-S7-A5 | none (live-LOGO! field-only) |
|
||||
| PR-S7-B1 | none |
|
||||
| PR-S7-B2 | none |
|
||||
| PR-S7-C1 | none |
|
||||
| PR-S7-C2 | none (live-firmware-only) |
|
||||
| PR-S7-C3 | none (subscribe assertion already covers polling) |
|
||||
| PR-S7-C4 | none |
|
||||
| PR-S7-C5 | none (probe runs first today) |
|
||||
| PR-S7-D1 | none (Admin UI has its own e2e) |
|
||||
| PR-S7-D2 | add UDT-member round-trip assertion |
|
||||
| PR-S7-D3 | none (import-time concern) |
|
||||
| PR-S7-E1 | none |
|
||||
| PR-S7-E2 | none (live-firmware-only) |
|
||||
| PR-S7-F | none (decision PR; live-firmware checklist instead) |
|
||||
|
||||
---
|
||||
|
||||
## Skip-rated items (for context)
|
||||
|
||||
| # | Gap | Skip rationale |
|
||||
|---|-----|---------------|
|
||||
| 12 | AS-Alarms / Alarm_S / ProDiag | Alarms are a separate workstream; no `IAlarmSource` shipped on this driver yet, and the gap analysis flags it as a deferred topic. |
|
||||
| 13 | CPU Run / Stop control / block download | Security and safety risk. PG-class writes that change CPU state are explicitly out of scope. |
|
||||
| 15 | S7-1500 Secure Communication / TLS | Significant work; S7netplus has no TLS surface. Reconsider when S7Plus track is taken. |
|
||||
| 16 | S7-400H redundant H-system support | Rare in our deployment scope. Server-level redundancy (`docs/Redundancy.md`) covers the OPC UA layer; H-system driver-level failover is a separate axis. |
|
||||
| 17 | Multi-CPU rack parallel sessions | One session per CPU works for the deployments we target; multi-CPU racks are an S7-400 niche. |
|
||||
| 18 | MPI / Profibus / RFC1006-routed transports | Declining use; brownfield only. S7netplus is Ethernet-only. |
|
||||
| 23 | Connection-resource budget / parallel jobs | One connection works; premature optimization until a deployment hits the cap. |
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
### (a) Library choice for S7Plus
|
||||
|
||||
PR-S7-F gates on this decision. Options:
|
||||
|
||||
1. **Stay on S7netplus + document Optimized-DB constraint** (preferred default).
|
||||
2. **Fork to Sharp7 / Snap7 v2** — does *not* solve the S7Plus / Optimized-DB
|
||||
problem; both are classic S7comm only. Adopting them buys nothing for this
|
||||
gap. Reject unless we want it for unrelated reasons.
|
||||
3. **Custom S7Plus client over Wireshark-dissected protocol** — large effort,
|
||||
ongoing maintenance risk. Only if a customer is paying.
|
||||
4. **OPC UA → OPC UA bridge via existing OPC UA Client driver** — sidesteps
|
||||
S7Plus by re-using Siemens's onboard OPC UA server. Recommended secondary
|
||||
track.
|
||||
|
||||
Decision needed before Phase 6 PR-S7-F kicks off.
|
||||
|
||||
### (b) `WriteIdempotent` semantics for new types
|
||||
|
||||
The `WriteIdempotent` per-tag flag (decisions #44, #45, #143) governs replay-
|
||||
safe writes. New types from Phase 1:
|
||||
|
||||
- **STRING / WSTRING** — typically idempotent (recipe / message text).
|
||||
Replay-safe by default? **Need confirmation.** Risk: PLC programs that
|
||||
treat a new string write as a "new message" event would double-fire.
|
||||
- **DTL / DT** — usually written from a clock master; replay-safe.
|
||||
- **Arrays of UDT** — depends on the UDT semantics (recipe = safe, command
|
||||
block = unsafe). Inherit `WriteIdempotent` from the parent tag, do not
|
||||
add a per-member flag.
|
||||
- **64-bit types** — same rule as 32-bit equivalents.
|
||||
|
||||
Default: keep `WriteIdempotent = false` for everything. Operators flip per
|
||||
tag based on PLC program semantics. **No semantic extension needed**, but
|
||||
document the per-type guidance in `docs/v2/s7.md`.
|
||||
|
||||
### (c) Symbol-import file format(s)
|
||||
|
||||
PR-S7-D1 ships an importer. Which formats?
|
||||
|
||||
- **TIA Portal CSV** (Show all tags / Export) — preferred entry point;
|
||||
most common. **Confirm.**
|
||||
- **TIA Portal SDF / Excel** — same data; harder to parse. Skip unless
|
||||
customer demand emerges.
|
||||
- **STEP 7 Classic AWL / SCL `.AWL`** — secondary. Useful for legacy
|
||||
S7-300/400 sites still on Classic. **Include in D1?**
|
||||
- **`.s7p` / `.zap` project archive** — full TIA project. ZIP-shaped;
|
||||
symbol export would require unpacking and parsing internal XML. Large
|
||||
scope. **Defer.**
|
||||
- **`.udt` / `.SDF` external tag library** — niche; defer unless asked.
|
||||
|
||||
Recommendation: PR-S7-D1 ships **TIA CSV** + **AWL** only. Anything else is
|
||||
a follow-up. Decision needed before Phase 4 work begins.
|
||||
899
docs/plans/twincat-plan.md
Normal file
899
docs/plans/twincat-plan.md
Normal file
@@ -0,0 +1,899 @@
|
||||
# TwinCAT Driver — Implementation Plan
|
||||
|
||||
> Source of gap analysis: [featuregaps.md → TwinCAT](../featuregaps.md#twincat-beckhoff-ads)
|
||||
>
|
||||
> Covers Build = Yes items only.
|
||||
|
||||
## Summary
|
||||
|
||||
The TwinCAT driver (`src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/`) ships a solid baseline:
|
||||
six capability interfaces over `Beckhoff.TwinCAT.Ads` v6 `AdsClient`, native
|
||||
`AdsTransMode.OnChange` notifications, AMS address parsing, symbol-path parser
|
||||
with multi-dim subscripts, controller-side browse with system-symbol filtering,
|
||||
and a 30-case live integration suite against TCBSD + Hyper-V XAR. Twelve gaps
|
||||
remain rated Build=Yes in `docs/featuregaps.md` and they cluster cleanly into
|
||||
five themes:
|
||||
|
||||
1. **Data-type correctness** — `LInt`/`ULInt` silently truncated to Int32
|
||||
(explicit `// matches Int64 gap` comment in `TwinCATDataType.cs:40`),
|
||||
`TIME`/`DATE`/`DT`/`TOD` marshalled as raw `UDINT` rather than native UA
|
||||
types, `ENUM`/`ALIAS` skipped at browse, bit-indexed BOOL writes throw,
|
||||
multi-dim and whole-array reads not batched.
|
||||
2. **Performance** — every read is a `ReadValueAsync` call with re-resolved
|
||||
symbolic name; no Sum commands, no handle caching. Multi-thousand-tag
|
||||
scans pay symbol resolution + per-tag AMS round-trip cost on every cycle.
|
||||
3. **Operability** — `NotificationSettings(OnChange, cycleMs, 0)` clamps
|
||||
max-delay to zero with no per-tag override; probe loop only checks
|
||||
reachability — no cycle-time / jitter / `_AppInfo` / RT-state telemetry.
|
||||
4. **UDT decomposition** — `Structure` is declared in the enum but discovery
|
||||
skips non-atomic symbols (`AdsTwinCATClient.cs:224`); to expose nested UDT
|
||||
trees we need TMC-file parsing or runtime data-type table introspection.
|
||||
5. **Alarms** — no `IAlarmSource` implementation; TC3 EventLogger / AMS port
|
||||
110 events never surface as OPC UA AC events.
|
||||
|
||||
The plan ships as five phases / 12 PRs. Phases 1-3 are all narrow scope and can
|
||||
land in parallel where dependencies allow. Phase 4 (UDT/TMC) is the largest
|
||||
single piece of work and is called out as such. Phase 5 (alarms) requires
|
||||
investigation up front (Beckhoff TC3 EventLogger NuGet availability — see
|
||||
Open questions).
|
||||
|
||||
Hyper-V conflict gating: live integration runs against the TCBSD VM
|
||||
(`docs/drivers/TwinCAT-Test-Fixture.md`, AmsNetId `41.169.163.43.1.1` at
|
||||
`10.100.0.128`) since the local Hyper-V XAR can't co-exist with Docker
|
||||
Desktop. All wire-level tests gate on `[TwinCATFact]` / `[TwinCATTheory]`
|
||||
and skip cleanly when `TWINCAT_TARGET_NETID` is unset.
|
||||
|
||||
## Phased delivery
|
||||
|
||||
| Phase | Theme | PRs | Sequencing |
|
||||
|---|---|---|---|
|
||||
| 1 | Data-type correctness | 1.1 — 1.5 | Independent; ship in any order |
|
||||
| 2 | Performance — Sum + handles | 2.1 — 2.3 | 2.3 depends on 2.2 |
|
||||
| 3 | Operability — max-delay + diagnostics | 3.1 — 3.2 | Independent |
|
||||
| 4 | UDT decomposition with TMC parsing | 4.1 | Stand-alone; significant scope |
|
||||
| 5 | TC3 EventLogger alarms | 5.1 | Stand-alone; spike first |
|
||||
|
||||
Total: 12 PRs covering the 12 Build=Yes gaps.
|
||||
|
||||
Recommended landing order: **Phase 1 (correctness) → Phase 3 (operability) →
|
||||
Phase 2 (perf) → Phase 5 (alarms) → Phase 4 (UDT)**. Correctness first because
|
||||
it's cheap and removes fixtures' `Skip("Int64 gap")`-style workarounds.
|
||||
Operability before perf because the diagnostics surface created in 3.2 makes it
|
||||
much easier to validate Sum-command throughput claims in 2.1.
|
||||
|
||||
## Per-PR detail
|
||||
|
||||
### Phase 1 — Data-type correctness
|
||||
|
||||
#### PR 1.1 — Int64 fidelity for `LINT` / `ULINT`
|
||||
|
||||
**Scope**: Map `LInt`/`ULInt` to `DriverDataType.Int64` (currently truncates to
|
||||
Int32 per `TwinCATDataType.cs:40` comment "matches Int64 gap"). `MapToClrType`
|
||||
already returns `typeof(long)`/`typeof(ulong)`; the truncation is purely in the
|
||||
`ToDriverDataType` extension.
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs` — change line 40 to
|
||||
`=> DriverDataType.Int64;` (drop the gap comment).
|
||||
- Verify `DriverDataType.Int64` exists in `Core.Abstractions` — if not, add it
|
||||
(likely scope creep into `ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs`).
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: none — the wire-level `AdsClient.ReadValueAsync`
|
||||
already returns `long`/`ulong` boxed in `result.Value` when called with
|
||||
`typeof(long)` per `MapToClrType`.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: extend `TwinCATCapabilityTests` — assert `LInt.ToDriverDataType() ==
|
||||
Int64`, `ULInt.ToDriverDataType() == Int64`.
|
||||
- Integration: extend `GVL_Primitives` to include an `LINT` (`nLargeCounter`)
|
||||
seeded with `0x1_0000_0000L` (above Int32 range). Add a `[TwinCATTheory]`
|
||||
case asserting the value round-trips without truncation. May need a new
|
||||
`GVL_Primitives.lLong : LINT` symbol if not already present (the existing
|
||||
16-primitive theory in `TwinCAT3SmokeTests.cs` covers `LInt`/`ULInt` —
|
||||
inspect what value it seeds and tighten the assertion).
|
||||
|
||||
**Effort**: S (half day).
|
||||
**Deps**: none.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/Driver.TwinCAT.Cli.md` "Data types" table — drop the "marshal as
|
||||
`UDINT` on the wire" caveat for `LInt` / `ULInt` (this PR keeps Int64 fidelity);
|
||||
`docs/drivers/TwinCAT-Test-Fixture.md` "Bugs caught by live runs" gains a 4th
|
||||
entry pinning the truncation regression.
|
||||
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Primitives.TcGVL` adds
|
||||
`vLargeCounter : LINT := 16#1_0000_0000` (above Int32 range) + matching
|
||||
`vLargeCounterU : ULINT`; `tests/.../TwinCatProject/README.md` "GVL_Primitives
|
||||
numeric seeds" enumerates the new symbols.
|
||||
- Integration tests: `TwinCAT3SmokeTests.cs` — extend the 16-case
|
||||
`[TwinCATTheory]` to 17/18 cases covering the new LINT/ULINT seeds; assert
|
||||
the value round-trips without truncation.
|
||||
- E2E: no change to `scripts/e2e/test-twincat.ps1` — the bridge script targets
|
||||
a single DINT counter, untouched by Int64 work.
|
||||
|
||||
#### PR 1.2 — TIME / DATE / DT / TOD as native UA types
|
||||
|
||||
**Scope**: Stop marshalling `TIME` / `DATE` / `DT` / `TOD` as raw `UDINT`
|
||||
(`AdsTwinCATClient.cs:278-280`). Map according to IEC 61131-3 semantics:
|
||||
|
||||
- `TIME` (ms duration) → `DriverDataType.Duration` (UA `Double` seconds, or
|
||||
add `Duration` to `DriverDataType` if missing).
|
||||
- `DATE` (days since 1970-01-01) → `DriverDataType.DateTime` (midnight UTC).
|
||||
- `DT` (seconds since 1970-01-01) → `DriverDataType.DateTime`.
|
||||
- `TOD` (ms since midnight) → `DriverDataType.DateTime` (today's date +
|
||||
offset) or a dedicated `TimeOfDay` type if the abstraction supports it.
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs` — update
|
||||
`ToDriverDataType` mapping for the four IEC time types.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — `MapToClrType`
|
||||
returns the raw UDINT today; keep that for the wire read but post-process
|
||||
inside `ReadValueAsync` / `ConvertForWrite` to convert UDINT ↔ `DateTime` /
|
||||
`TimeSpan`. Symmetrical change in `OnAdsNotificationEx` so subscriptions see
|
||||
the same shape.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: still `AdsClient.ReadValueAsync(symbol,
|
||||
typeof(uint), ct)`. Beckhoff exposes `PlcOpenDate` / `PlcOpenTimeOfDay` etc.
|
||||
in `TwinCAT.Ads.TypeSystem` — using those types directly would simplify
|
||||
conversion but tightens our coupling. Investigate during PR.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: round-trip helpers UDINT-since-epoch ↔ `DateTime` for each variant.
|
||||
- Integration: add `GVL_Primitives.dCurrentTime : DT` seeded with a known
|
||||
literal (e.g. `DT#2026-01-15-12:00:00`); assert the driver returns a
|
||||
`DateTime` matching that instant within 1 s.
|
||||
|
||||
**Effort**: M (1-2 days).
|
||||
**Deps**: none. May expose missing `Duration` in `DriverDataType` enum.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/Driver.TwinCAT.Cli.md` "Data types" section — replace the
|
||||
"marshal as `UDINT` on the wire — CLI takes a numeric raw value" paragraph
|
||||
with native syntax (e.g. `read -t DateTime` returns ISO-8601, `write -t Time
|
||||
-v 00:00:01.500` for IEC TIME duration). New examples for each of the four
|
||||
IEC time types under `read` / `write`.
|
||||
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Primitives.TcGVL` adds
|
||||
`dCurrentTime : DT := DT#2026-01-15-12:00:00`, `tCycleDuration : TIME :=
|
||||
T#1500ms`, `dToday : DATE := DATE#2026-04-25`, `tShiftStart : TOD :=
|
||||
TOD#06:30:00`. Existing primitives theory in
|
||||
`tests/.../TwinCatProject/README.md` § "Type coverage" gets the seed values
|
||||
documented.
|
||||
- Integration tests: `TwinCAT3SmokeTests.cs` — new
|
||||
`Driver_round_trips_TIME_DATE_DT_TOD_as_native_UA_types` `[TwinCATFact]`
|
||||
reading each variable and asserting the CLR shape (`TimeSpan` / `DateTime`).
|
||||
Update the existing 16-case primitive `[TwinCATTheory]` to assert native
|
||||
types instead of raw `UDINT` for these four entries.
|
||||
- E2E: `scripts/e2e/test-twincat.ps1` unchanged for now (single DINT bridge);
|
||||
follow-up could add a DT-typed bridge node but it's not on the critical path.
|
||||
|
||||
#### PR 1.3 — Bit-indexed BOOL writes (read-modify-write)
|
||||
|
||||
**Scope**: Replace the `NotSupportedException` at `AdsTwinCATClient.cs:99-100`
|
||||
with a read-modify-write sequence: read parent word as `uint`, set/clear bit,
|
||||
write the word back. Must serialize against concurrent writes to the same
|
||||
parent word — a single `SemaphoreSlim` keyed on parent symbol path is
|
||||
sufficient (concurrency on bit writes within the same parent is rare and the
|
||||
PLC cycle is the natural lower bound on contention anyway).
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — replace `throw`
|
||||
branch in `WriteValueAsync` with RMW logic mirroring `ReadValueAsync`'s
|
||||
bit-index path. Add `ConcurrentDictionary<string, SemaphoreSlim>
|
||||
_bitWriteLocks` keyed on parent symbol.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: `AdsClient.ReadValueAsync(parent, typeof(uint))`
|
||||
+ `AdsClient.WriteValueAsync(parent, modifiedWord)`. Both already used.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: extend `TwinCATReadWriteTests` with a `FakeTwinCATClient` test
|
||||
covering set + clear of bits 0, 7, 15, 31 of a `uint` parent.
|
||||
- Integration: add a new `[TwinCATFact]` —
|
||||
`Driver_round_trips_bit_indexed_BOOL_write_and_read` against
|
||||
`GVL_Primitives.vWord.4` (the `0xBEEF` word's bit-4); flip to true, read
|
||||
back as true, flip to false, read back as false.
|
||||
|
||||
**Effort**: S-M (1 day).
|
||||
**Deps**: none. Closes task #181 referenced in the existing `NotSupported`
|
||||
exception message.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/Driver.TwinCAT.Cli.md` `write` section — add an example
|
||||
`otopcua-twincat-cli write -n ... -s "GVL_Primitives.vWord.4" -t Bool -v
|
||||
true` and a note explaining the RMW semantics + concurrency caveat (parent
|
||||
word is locked per write — concurrent bit writes on the same word
|
||||
serialize). `docs/drivers/TwinCAT-Test-Fixture.md` "Bugs caught by live
|
||||
runs" updates entry #3 to note that writes now also work (read previously
|
||||
shipped; write was the gap).
|
||||
- Fixture (TCBSD PLC project): no schema change required —
|
||||
`GVL_Primitives.vWord` already exists with seed `0xBEEF`. Tests use bits 4
|
||||
(clear) and 7 (set) to round-trip.
|
||||
- Integration tests: `TwinCAT3SmokeTests.cs` — new
|
||||
`Driver_round_trips_bit_indexed_BOOL_write_and_read` `[TwinCATFact]`. Unit
|
||||
tests in `TwinCATReadWriteTests` extended via `FakeTwinCATClient` for bits
|
||||
0/7/15/31 of a `uint` parent.
|
||||
- E2E: no change.
|
||||
|
||||
#### PR 1.4 — Multi-dim and whole-array reads
|
||||
|
||||
**Scope**: Expand `ReadValueAsync` / `WriteValueAsync` to handle whole-array
|
||||
reads via Beckhoff's array marshalling, instead of element-by-element. The
|
||||
symbol-path parser already produces `TwinCATSymbolSegment.Subscripts` with N
|
||||
dims; today the driver only reads single elements (one path per request).
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — when a tag
|
||||
declares `IsArray=true` (extend `TwinCATTagDefinition`), use
|
||||
`AdsClient.ReadValueAsync(symbol, typeof(int[]))` / `typeof(double[,])` etc.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — surface
|
||||
`IsArray` + `ArrayDim` through `DriverAttributeInfo` in `DiscoverAsync`.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTagDefinition.cs` (if exists,
|
||||
in `TwinCATDriverOptions.cs`) — add `bool IsArray`, `int[]? ArrayDimensions`.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: `AdsClient.ReadValueAsync(symbol, Type, ct)`
|
||||
accepts CLR array types. For dynamically-sized reads use
|
||||
`AdsClient.ReadAnyAsync<T[]>(...)` or pass `Array.CreateInstance(elemType,
|
||||
dims)`. SymbolLoader yields a `Symbol.Category == DataTypeCategory.Array` we
|
||||
can inspect to autoderive dimensions during discovery.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: parse `Matrix[1,2]` and verify ranking / dimension flow into the
|
||||
request shape via `FakeTwinCATClient`.
|
||||
- Integration: extend `GVL_Arrays` with a 5x5 `aReal2D : ARRAY [1..5, 1..5]
|
||||
OF REAL`; new `[TwinCATFact]` reads the whole array in one call and
|
||||
verifies element count + values.
|
||||
|
||||
**Effort**: M (2-3 days).
|
||||
**Deps**: none. Sets up the array-shape plumbing the rest of the driver
|
||||
needs anyway.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/Driver.TwinCAT.Cli.md` `read` section — add whole-array example
|
||||
(`read -s "GVL_Arrays.aReal2D"` returns the full matrix as JSON) plus a
|
||||
dedicated "Arrays" sub-section calling out 1-D / N-D / array-of-struct
|
||||
semantics. `docs/drivers/TwinCAT-Test-Fixture.md` "What it actually covers"
|
||||
list adds the whole-array bullet.
|
||||
- Fixture (TCBSD PLC project): `PLC/GVLs/GVL_Arrays.TcGVL` already declares
|
||||
`ARRAY[1..4,1..4] OF REAL` per `TwinCatProject/README.md` § "Array
|
||||
coverage". This PR adds a 5x5 `aReal2D : ARRAY [1..5, 1..5] OF REAL`
|
||||
initialised with a deterministic pattern (e.g. `(i-1)*5 + (j-1)`) so the
|
||||
whole-array test can assert each element. README "Array coverage" gets the
|
||||
new symbol.
|
||||
- Integration tests: `TwinCAT3SmokeTests.cs` — new
|
||||
`Driver_reads_whole_2D_array_in_one_call` `[TwinCATFact]`. Unit tests
|
||||
extend `TwinCATSymbolPathTests` for multi-dim subscript shape.
|
||||
- E2E: no change to `scripts/e2e/test-twincat.ps1` (scalar bridge); a future
|
||||
array-bridge scenario is captured in the consolidated section below.
|
||||
|
||||
#### PR 1.5 — ENUM and ALIAS at discovery
|
||||
|
||||
**Scope**: `MapSymbolTypeName` returns `null` for any non-atomic type
|
||||
(`AdsTwinCATClient.cs:224`), so ENUM and ALIAS symbols are silently dropped
|
||||
during browse. ENUM is essentially a sized-integer with named members; ALIAS
|
||||
is a renamed atomic. Both are extremely common in real projects (motor states,
|
||||
recipe-step IDs, bit-flag groups).
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` —
|
||||
`MapSymbolTypeName` keyed only on the type name today; switch to inspecting
|
||||
`symbol.DataType` + `symbol.Category` from `TwinCAT.TypeSystem`. For
|
||||
`DataTypeCategory.Enum` walk `EnumType.EnumValues` and pick the underlying
|
||||
base type. For `DataTypeCategory.Alias` resolve `AliasType.BaseType`
|
||||
recursively until atomic.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/BrowseSymbolsAsync` —
|
||||
surface enum members so the OPC UA layer can later emit them as
|
||||
EnumStrings.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: `TwinCAT.Ads.TypeSystem.SymbolLoaderFactory`
|
||||
already returns full `IDataType` objects with `Category`, `EnumType`,
|
||||
`AliasType`, etc. No new APIs.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: extend `TwinCATSymbolBrowserTests` — fake an enum symbol via
|
||||
`FakeTwinCATClient`; assert it browses with the underlying base type.
|
||||
- Integration: add `E_LineState : (Idle, Running, Faulted)` + a GVL instance
|
||||
variable; new `[TwinCATFact]` browses + reads it as `Int16` (or whatever
|
||||
the underlying type is).
|
||||
|
||||
**Effort**: M (1-2 days).
|
||||
**Deps**: none. POINTER / REFERENCE / INTERFACE / UNION are explicitly
|
||||
out-of-scope for this PR — they need real-world demand and a much larger
|
||||
type-system rework. ENUM and ALIAS are the 80% case.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/Driver.TwinCAT.Cli.md` `browse` section — note that ENUM and
|
||||
ALIAS symbols now appear in the output (previously dropped); add a Data
|
||||
types row for "Enum (surfaced as underlying integer with EnumStrings)"
|
||||
and "Alias (resolved to base atomic)". `docs/drivers/TwinCAT-Test-
|
||||
Fixture.md` "What it actually covers" extends with the enum/alias bullet.
|
||||
- Fixture (TCBSD PLC project): `PLC/DUTs/E_AxisState.TcDUT` and
|
||||
`E_Severity.TcDUT` already exist; `PLC/DUTs/T_Temperature.TcDUT` and
|
||||
`T_MeterPerSec.TcDUT` already exist. `PLC/GVLs/GVL_Enums.TcGVL` already
|
||||
exposes them at the root per `TwinCatProject/README.md` § "Enum + alias
|
||||
coverage" — no fixture change needed for this PR. README's "Integration-
|
||||
test contract" gets a new entry for `GVL_Enums.currentSeverity` /
|
||||
`currentTemperature` so the new browse assertion has a stable target.
|
||||
- Integration tests: `TwinCAT3SmokeTests.cs` — new
|
||||
`Driver_browses_enums_and_aliases_with_resolved_base_types` `[TwinCATFact]`
|
||||
asserting the four `GVL_Enums` symbols surface with the correct underlying
|
||||
CLR type (`Int32` for E_AxisState, `Int16` for E_Severity, `Double` for
|
||||
the LREAL aliases).
|
||||
- E2E: no change.
|
||||
|
||||
### Phase 2 — Performance (Sum commands + handle caching)
|
||||
|
||||
#### PR 2.1 — ADS Sum-read / Sum-write
|
||||
|
||||
**Scope**: Today `ReadAsync` loops over `fullReferences` issuing one
|
||||
`ReadValueAsync` per tag (`TwinCATDriver.cs:118-156`). Beckhoff's ADS Sum
|
||||
commands (`IndexGroup=0xF080..0xF084`) batch N reads/writes into a single AMS
|
||||
request. `Beckhoff.TwinCAT.Ads` v6 exposes this via
|
||||
`AdsClient.ReadWriteAsync` with `SumCommand` request envelopes —
|
||||
specifically `SumSymbolRead` / `SumSymbolWrite` from
|
||||
`TwinCAT.Ads.SumCommand`. ~10x throughput on multi-thousand-tag scans
|
||||
according to Beckhoff InfoSys.
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — new
|
||||
`ReadValuesAsync(IReadOnlyList<(string symbol, Type clrType)>, ct)` returning
|
||||
a parallel array of `(value, status)`.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::ReadAsync` — bucket
|
||||
`fullReferences` by `DeviceHostAddress`, call the new client method per
|
||||
bucket. `bitIndex` handling stays per-tag (RMW post-step).
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs` — add the
|
||||
bulk-read / bulk-write surface.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**:
|
||||
- `AdsClient.ReadWriteAsync(IndexGroup=0xF080, IndexOffset=count, ...)` for
|
||||
raw sum-read by handle.
|
||||
- Higher-level: `TwinCAT.Ads.SumCommand.SumSymbolRead(client, symbols)` /
|
||||
`SumSymbolWrite(client, symbols, values)` in v6. Verify the exact namespace
|
||||
during PR — Beckhoff sometimes re-shuffles between minor versions.
|
||||
- For symbolic (no handle) batching: `SumSymbolReadByName`.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: `FakeTwinCATClient.ReadValuesAsync` fakes the bulk surface; test
|
||||
ordering preservation, partial-failure mapping, empty-input handling.
|
||||
- Integration: `[TwinCATFact]` reads 100 declared tags in one call, asserts
|
||||
value parity with 100 single-call equivalents and measures wall-clock
|
||||
difference (assert under 50% of the loop baseline).
|
||||
|
||||
**Effort**: M-L (3 days).
|
||||
**Deps**: none (handle caching in 2.2 amplifies the win but isn't required).
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/v3/twincat-backlog.md` perf note moves out (Sum-commands no
|
||||
longer deferred) — add a closed-out bullet pointing at this PR. New
|
||||
performance section in `docs/drivers/TwinCAT-Test-Fixture.md` documenting
|
||||
the throughput baseline + Sum-command delta. `docs/Driver.TwinCAT.Cli.md`
|
||||
doesn't expose Sum directly to the user — the CLI still drives one symbol
|
||||
per call — so no CLI doc change.
|
||||
- Fixture (TCBSD PLC project, primary fixture-extension surface): add a new
|
||||
`PLC/GVLs/GVL_Perf.TcGVL` declaring `aTags : ARRAY[1..1000] OF DINT` plus
|
||||
a `MAIN` rung (or new `FB_PerfChurn` POU) that increments each element on
|
||||
a rotating subset. `TwinCatProject/README.md` § "Required project state"
|
||||
gains a "Performance scenarios" subsection documenting the 1000-tag GVL.
|
||||
- Integration tests: new perf test
|
||||
`Driver_sum_read_1000_tags_beats_loop_baseline_by_5x` (`[TwinCATFact]`,
|
||||
perf-tier — guarded behind a separate `TWINCAT_PERF=1` env flag so CI
|
||||
noise from VM jitter doesn't flap the suite). Unit tests cover ordering,
|
||||
partial-failure mapping, empty-input via `FakeTwinCATClient.ReadValuesAsync`.
|
||||
- E2E: `scripts/e2e/test-twincat.ps1` unchanged for the canonical bridge;
|
||||
perf scripts live alongside as a separate `scripts/perf/twincat-sum.ps1`
|
||||
if/when introduced (deferred — integration test is sufficient).
|
||||
|
||||
#### PR 2.2 — Handle-based access with caching
|
||||
|
||||
**Scope**: Cache `AdsClient.CreateVariableHandleAsync` results so per-read
|
||||
overhead drops from "resolve symbolic name + read by name" to "read by handle"
|
||||
— smaller AMS payloads, no name resolution on each call. Cache lifetime is
|
||||
process-scoped; eviction is via the PR 2.3 invalidation listener. Until 2.3
|
||||
ships the cache must be cleared on `AdsClient` reconnect (the existing
|
||||
auto-reconnect path in `EnsureConnectedAsync`).
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — add
|
||||
`ConcurrentDictionary<string, uint> _handleCache`. Wrap reads/writes through
|
||||
`EnsureHandleAsync(symbolPath)` that hits the cache or calls
|
||||
`CreateVariableHandleAsync`. On `AdsErrorCode.DeviceSymbolVersionInvalid`
|
||||
(0x710 / 1808) evict the entry and retry once.
|
||||
- Dispose path: `DeleteVariableHandleAsync` for every cached handle on
|
||||
`AdsClient.Dispose` to be a good citizen with the runtime.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**:
|
||||
- `AdsClient.CreateVariableHandleAsync(string symbol, ct)` → returns
|
||||
`ResultHandle` with `.Handle` (uint).
|
||||
- `AdsClient.ReadAnyAsync<T>(IndexGroup=0xF005, IndexOffset=handle, ct)`
|
||||
reads by handle.
|
||||
- `AdsClient.WriteAnyAsync(IndexGroup=0xF005, IndexOffset=handle, value, ct)`.
|
||||
- `AdsClient.DeleteVariableHandleAsync(uint handle, ct)`.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: `FakeTwinCATClient` records handle-create / read-by-handle calls;
|
||||
test asserts second read of same symbol uses cached handle (zero new
|
||||
creates).
|
||||
- Integration: subscribe + read 50 tags, capture AMS round-trips via probe
|
||||
counter, assert the second pass uses ~50% of the bytes (handle = 4 bytes
|
||||
vs symbol path = N bytes).
|
||||
|
||||
**Effort**: M (2 days).
|
||||
**Deps**: combines with PR 2.1 for sum-read-by-handle (highest perf path).
|
||||
Without 2.3, handles can go stale after an online change — call out the
|
||||
caveat in driver options and add a manual `FlushOptionalCachesAsync` invocation
|
||||
that wipes the handle cache.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/drivers/TwinCAT-Test-Fixture.md` perf section gets a paragraph
|
||||
noting that handles drop AMS payload size for repeated reads (4 bytes vs.
|
||||
N-byte symbol path); call out the staleness caveat (online-change
|
||||
invalidation lands in 2.3). `docs/Driver.TwinCAT.Cli.md` adds a brief note
|
||||
in the `subscribe` / `read` sections that handles are cached transparently
|
||||
— no user-visible flag.
|
||||
- Fixture (TCBSD PLC project): no change required — handle caching is
|
||||
observable via byte-counter on the wire, not via PLC-side state. The
|
||||
perf-scenario `GVL_Perf.aTags` from PR 2.1 doubles as the exercise target.
|
||||
- Integration tests: new
|
||||
`Driver_handle_cache_avoids_repeat_symbol_resolution` `[TwinCATFact]`
|
||||
reads the same 50 symbols twice; asserts second pass uses cached handles
|
||||
(probed via diagnostics counters from PR 3.2 if shipped, otherwise via a
|
||||
test-only hook on `AdsTwinCATClient`). Unit tests on
|
||||
`FakeTwinCATClient.HandleCacheTests` assert second read of same symbol
|
||||
triggers zero new handle creates.
|
||||
- E2E: no change.
|
||||
|
||||
#### PR 2.3 — Symbol-version invalidation listener
|
||||
|
||||
**Scope**: TwinCAT publishes a "symbol table version changed" notification on
|
||||
ADS Index Group `ADSIGRP_SYMVAL_BYHND` (or rather, version bumps land via
|
||||
`SystemServiceLoadFile` style notifications + `SymbolVersion` reads). When the
|
||||
PLC takes an online change, all cached handles are silently invalidated; the
|
||||
next read returns `DeviceSymbolVersionInvalid` if you're lucky and a wrong
|
||||
value if you're not. We register a notification on the symbol-version index
|
||||
and wipe the handle cache on bump.
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` — on connect,
|
||||
call `AddDeviceNotificationAsync(ADSIGRP_SYM_VERSION, 0, length=1, ...)`
|
||||
with `AdsTransMode.OnChange`. On callback, clear `_handleCache` + log.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**:
|
||||
- `AdsClient.AddDeviceNotificationAsync(uint indexGroup, uint indexOffset,
|
||||
int length, NotificationSettings, object userData, ct)` — the raw,
|
||||
index-group-based variant (not the symbol-name `Ex` variant we use today).
|
||||
- Index group: `AdsReservedIndexGroup.SymbolVersion` (0xF008). One byte
|
||||
payload that's the current symbol-version counter. Confirm during PR — open
|
||||
question (c) below.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: extend `TwinCATNativeNotificationTests` — `FakeTwinCATClient` exposes
|
||||
a `FireSymbolVersionChange()` method; test asserts handle cache is cleared
|
||||
and subsequent reads recreate handles.
|
||||
- Integration: `[TwinCATFact]` triggers an online change on the TCBSD project
|
||||
(rebuild a GVL with one new variable + login activate) — needs a project
|
||||
helper that automates the online-change. May ship behind a manual gate
|
||||
(`[TwinCATFact(Reason="requires-manual-online-change")]`) initially.
|
||||
|
||||
**Effort**: M (2 days).
|
||||
**Deps**: PR 2.2 (no point invalidating an empty cache). Confirm
|
||||
`SymbolVersion` index-group constant in `Beckhoff.TwinCAT.Ads` v6 — open
|
||||
question (c) below.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/drivers/TwinCAT-Test-Fixture.md` section on "What it does NOT
|
||||
cover" — drop the implicit "online-change handling" gap. New paragraph in
|
||||
the perf section noting handle cache is now self-invalidating.
|
||||
`docs/Driver.TwinCAT.Cli.md` no change (transparent to CLI user).
|
||||
- Fixture (TCBSD PLC project): no schema change. Operator workflow gains an
|
||||
online-change drill — `TwinCatProject/README.md` adds a § "Online-change
|
||||
test scenario" describing the steps (open project, add a dummy variable
|
||||
to `GVL_Perf`, "Login + Activate" → triggers the symbol-version bump).
|
||||
This is the manual gate for the integration assertion.
|
||||
- Integration tests: new `Driver_invalidates_handle_cache_on_symbol_version_bump`
|
||||
`[TwinCATFact]` — initially gated `[TwinCATFact(Reason="requires-manual-online-change")]`
|
||||
until automation lands. Unit tests cover the callback path via
|
||||
`FakeTwinCATClient.FireSymbolVersionChange()`.
|
||||
- E2E: no change.
|
||||
|
||||
### Phase 3 — Operability
|
||||
|
||||
#### PR 3.1 — Per-tag MaxDelay tuning
|
||||
|
||||
**Scope**: Today `NotificationSettings` is hard-coded as `(OnChange, cycleMs,
|
||||
0)` (`AdsTwinCATClient.cs:144-145`). MaxDelay=0 means "fire as soon as the
|
||||
change is detected, no coalescing"; for bursty high-frequency signals this
|
||||
floods the OPC UA subscription queue. Surface MaxDelay as a per-tag option
|
||||
(default 0 to preserve current behavior).
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs` — add
|
||||
`int? MaxDelayMs` to `TwinCATTagDefinition`.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::SubscribeAsync` —
|
||||
pass through to client.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs::AddNotificationAsync`
|
||||
— accept `int maxDelayMs`, plumb into `NotificationSettings(...,
|
||||
cycleMs, maxDelayMs)`.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: `NotificationSettings(AdsTransMode mode, int
|
||||
cycleTime, int maxDelay)` — both args in milliseconds per Beckhoff InfoSys
|
||||
`tcadsnetref/7313319051`.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: extend `TwinCATNativeNotificationTests` — assert the plumbed
|
||||
`maxDelayMs` lands on `NotificationSettings`.
|
||||
- Integration: subscribe to `GVL_Fixture.nCounter` with `MaxDelayMs=500`;
|
||||
assert delivery rate is ≤ 2 Hz even when PLC cycle is 10 ms.
|
||||
|
||||
**Effort**: S (half day).
|
||||
**Deps**: none.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: `docs/Driver.TwinCAT.Cli.md` `subscribe` flag table — add `--max-delay-ms`
|
||||
with default `0` and a note that nonzero coalesces high-frequency PLC
|
||||
signals. Update the description of `-i` / `--interval-ms` to disambiguate
|
||||
cycle vs. max-delay (both pass through to `NotificationSettings`).
|
||||
`docs/drivers/TwinCAT-Test-Fixture.md` "Notification coalescing under
|
||||
jitter" caveat — noting per-tag MaxDelay is now configurable.
|
||||
- Fixture (TCBSD PLC project): no change required — `GVL_Fixture.nCounter`
|
||||
already increments on every 10 ms cycle (see `MAIN.TcPOU`), so the test
|
||||
can drive a 100 Hz change rate and verify ≤ 2 Hz delivery with
|
||||
`MaxDelayMs=500`. README "Required project state" gets a one-line note
|
||||
that the counter doubles as the coalescing-test driver.
|
||||
- Integration tests: new `Driver_coalesces_notifications_at_max_delay`
|
||||
`[TwinCATFact]` subscribes to `GVL_Fixture.nCounter` with `MaxDelayMs=500`
|
||||
and asserts delivered-event count ≤ 3 over a 1 s window.
|
||||
- E2E: `scripts/e2e/test-twincat.ps1` `Test-SubscribeSeesChange` is a
|
||||
one-shot subscribe; no change. A future high-rate variant could test
|
||||
coalescing end-to-end through the OPC UA bridge but it's not on the
|
||||
critical path.
|
||||
|
||||
#### PR 3.2 — Cycle-time / jitter / PLC-state diagnostics
|
||||
|
||||
**Scope**: Probe loop today only checks reachability via `ReadStateAsync`
|
||||
(`TwinCATDriver.cs::ProbeLoopAsync`). Surface cycle-time, jitter, and online-
|
||||
change counter as health signals via the standard `_AppInfo` /
|
||||
`TwinCAT_SystemInfoVarList._AppInfo` GVL (the same one we filter out of
|
||||
discovery). Specifically:
|
||||
|
||||
- `_AppInfo.OnlineChangeCnt` (UDINT) — incremented on every online change.
|
||||
- `_AppInfo.AppName` (STRING) — TC project name, useful for
|
||||
cross-instance identification.
|
||||
- `_TaskInfo[1].CycleTime` (UDINT, 100 ns units) — the configured PLC cycle.
|
||||
- `_TaskInfo[1].LastExecTime` (UDINT, 100 ns units) — most recent measured
|
||||
cycle execution; jitter is the delta against `CycleTime`.
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::ProbeLoopAsync` —
|
||||
augment success path to also read these four symbols. Surface via a new
|
||||
`TwinCATDeviceDiagnostics` record on `DeviceState`. Emit through
|
||||
`IDriverDiagnostics` (the cross-driver diagnostics surface introduced for
|
||||
Modbus prohibition events — task #154).
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs` — leave
|
||||
the filter as-is for the user-visible browse; the probe path reads system
|
||||
symbols directly without going through discovery.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: still `AdsClient.ReadValueAsync(symbol, type,
|
||||
ct)`. The symbols are read by name, not by index group, so no new API.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: `FakeTwinCATClient` exposes `SetSystemSymbolValue(string name, object
|
||||
value)` so tests can drive the diagnostics surface deterministically.
|
||||
- Integration: `[TwinCATFact]` connects to TCBSD, asserts the diagnostics
|
||||
block populates `CycleTimeMs > 0` and `OnlineChangeCnt >= 0` within one
|
||||
probe interval.
|
||||
|
||||
**Effort**: M (1-2 days).
|
||||
**Deps**: confirm `IDriverDiagnostics` shape from existing Modbus diagnostics
|
||||
RPC (task #154 in MEMORY); it should be reusable.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: new section "Diagnostics" in `docs/drivers/TwinCAT-Test-Fixture.md`
|
||||
documenting the four exposed signals (cycle time, jitter, online-change
|
||||
counter, app name) and where they surface in the cross-driver
|
||||
diagnostics RPC. `docs/Driver.TwinCAT.Cli.md` `probe` section gains a
|
||||
"Health probe" sub-section noting the same symbols can be read directly
|
||||
via `probe -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"`
|
||||
(the existing example) plus the new `_TaskInfo[1].CycleTime` /
|
||||
`LastExecTime`. Add `docs/v3/twincat-backlog.md` cross-link confirming
|
||||
cycle-time/jitter no longer deferred.
|
||||
- Fixture (TCBSD PLC project): no change required — `_AppInfo` and
|
||||
`_TaskInfo[1]` are TwinCAT system GVLs, present on every runtime. The
|
||||
`TwinCATSystemSymbolFilter` already drops them from user browse;
|
||||
`TwinCatProject/README.md` adds a one-line "These symbols are read by
|
||||
the probe loop, not project-defined" callout.
|
||||
- Integration tests: new `Probe_loop_surfaces_cycle_time_and_online_change_count`
|
||||
`[TwinCATFact]` asserts the diagnostics record populates within one
|
||||
probe interval against TCBSD. Unit tests via `FakeTwinCATClient.SetSystemSymbolValue`
|
||||
drive the diagnostics surface deterministically.
|
||||
- E2E: no change. Future enhancement could expose driver diagnostics via a
|
||||
CLI subcommand (`otopcua-twincat-cli diagnostics -n ...`) — captured in
|
||||
the consolidated section below as a follow-up.
|
||||
|
||||
### Phase 4 — UDT decomposition with TMC parsing
|
||||
|
||||
#### PR 4.1 — Nested UDT browse via TMC parsing
|
||||
|
||||
**Scope**: Largest single piece of work in the plan. `TwinCATDataType.Structure`
|
||||
exists but `BrowseSymbolsAsync` skips non-atomic symbols
|
||||
(`AdsTwinCATClient.cs:224`); to expose nested UDT trees we either:
|
||||
|
||||
1. **Online**: walk the `IDataType` tree returned by `SymbolLoaderFactory` —
|
||||
each `IStructType` exposes `SubItems` recursively. This is what
|
||||
`Beckhoff.TwinCAT.Ads` v6's TypeSystem already gives us at runtime; we just
|
||||
never recursed.
|
||||
2. **Offline (TMC file)**: parse the TwinCAT Module Class XML file the project
|
||||
compiles to (`*.tmc`), build a type catalogue, drive discovery from it
|
||||
without requiring a live runtime.
|
||||
|
||||
We ship the **online** path first (PR 4.1) because it covers 100% of the case
|
||||
where the runtime is reachable, and `SymbolLoaderFactory` already does the
|
||||
heavy lifting. TMC offline parsing is deferred to a hypothetical PR 4.2 if a
|
||||
disconnected-discovery use case emerges (unlikely; live integration tests
|
||||
demonstrate runtime is always available in our deployments).
|
||||
|
||||
**Files**:
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs` —
|
||||
`BrowseSymbolsAsync` recurses into `IStructType.SubItems`, yielding one
|
||||
`TwinCATDiscoveredSymbol` per leaf with the dotted instance path
|
||||
(`MyStruct.Inner.Field`). For arrays-of-structs, expand element-by-element
|
||||
up to a configurable bound (default 1024) — beyond that, expose only the
|
||||
array root with `IsArray=true`.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs::DiscoverAsync` —
|
||||
fold the recursed structure into the existing `Discovered/` folder tree
|
||||
using `IAddressSpaceBuilder.Folder` for each struct member.
|
||||
- New: `TwinCATTypeWalker.cs` — pure helper that takes an `IDataType` and
|
||||
yields `(instancePath, atomicType, readOnly)` tuples. Unit-testable without
|
||||
touching `AdsClient`.
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**:
|
||||
- `TwinCAT.TypeSystem.IStructType` — `SubItems` (collection of
|
||||
`IMember`); each member has `BaseType`, `Name`, `Offset`.
|
||||
- `TwinCAT.TypeSystem.IArrayType` — `Dimensions`, `BaseType`.
|
||||
- `TwinCAT.TypeSystem.IEnumType` — handled in PR 1.5 (atomic surface).
|
||||
- `TwinCAT.TypeSystem.IAliasType.BaseType` — recurse until atomic.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: new `TwinCATTypeWalkerTests` — feed synthetic `IDataType` trees,
|
||||
assert the flattened paths and types.
|
||||
- Integration: extend `GVL_Plant` (already has `Line1.Stations[1].Axes[1].Motor`
|
||||
per `TwinCAT3SmokeTests.cs`) — the existing `Driver_reads_deeply_nested_UDT_path`
|
||||
test reads a known-leaf path; add a new test that browses into the same
|
||||
GVL and asserts the entire tree shape matches expectation. Should yield
|
||||
~50+ leaves.
|
||||
|
||||
**Effort**: L (4-5 days). Most of the cost is in the addressspace-builder
|
||||
folder/variable plumbing, not the type walking itself.
|
||||
**Deps**: PR 1.5 (ENUM/ALIAS) — without it, struct members of enum type
|
||||
silently drop. PR 1.4 (whole-array reads) is helpful but not blocking.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: this is the **largest doc-write of the plan**.
|
||||
`docs/Driver.TwinCAT.Cli.md` gains a new top-level "UDT decomposition"
|
||||
section explaining the dotted-instance browse syntax (`MyStruct.Inner.
|
||||
Field`), array-of-struct expansion bound, and how members surface via
|
||||
`browse`. The existing `read` example "Nested UDT member" gets expanded
|
||||
with a multi-level case targeting the plant hierarchy. `docs/drivers/
|
||||
TwinCAT-Test-Fixture.md` "What it actually covers" gets a UDT bullet
|
||||
per-member rather than per-leaf. Update `docs/v3/twincat-backlog.md` —
|
||||
remove the implicit UDT-decomposition gap.
|
||||
- Fixture (TCBSD PLC project, primary fixture-extension surface): the
|
||||
existing `GVL_Plant.Line1.Stations[1..3].Axes[1..4]...` 5-level
|
||||
hierarchy already provides ~50+ leaves per `TwinCatProject/README.md`
|
||||
§ "5-level plant hierarchy" + § "Live value churn". This PR may add a
|
||||
few **edge cases** to stress the type walker:
|
||||
- `PLC/DUTs/ST_NestedFlags.TcDUT` — struct containing a BIT-packed
|
||||
member (e.g. `Flags : DWORD` with named bit-mask aliases).
|
||||
- `PLC/DUTs/ST_RecursiveCap.TcDUT` — struct with a self-pointer (must
|
||||
be capped by the type walker, not infinite-recurse). Demonstrates
|
||||
POINTER skip behavior.
|
||||
- Add an `ARRAY [1..2000] OF ST_AlarmRecord` to exercise the
|
||||
`MaxArrayExpansion` (default 1024) cutoff.
|
||||
README § "Complex hierarchy" gets the new edge-case DUTs documented.
|
||||
- Integration tests: new `TwinCATTypeWalkerTests` (unit) feeding synthetic
|
||||
`IDataType` trees. Live: `Driver_browses_full_plant_hierarchy_yields_50_plus_leaves`,
|
||||
`Driver_caps_array_of_struct_expansion_at_configured_bound`,
|
||||
`Driver_handles_self_referential_struct_without_recursion` against the
|
||||
new edge-case DUTs.
|
||||
- E2E: `scripts/e2e/test-twincat.ps1` could gain a UDT-bridge scenario
|
||||
(`-BridgeNodeId` pointing at `GVL_Plant.Line1.Stations[1].Axes[1].Motor.
|
||||
Temperature`) but this requires the OPC UA server's address-space to
|
||||
reflect the decomposed tree — keep as a follow-up after server-side
|
||||
rendering ships in v3.
|
||||
|
||||
### Phase 5 — TC3 EventLogger alarms
|
||||
|
||||
#### PR 5.1 — `IAlarmSource` via TC3 EventLogger
|
||||
|
||||
**Scope**: TwinCAT 3.1 build 4022+ ships TcEventLogger as a system service
|
||||
exposing alarms/events on AMS port 110 (`AMSPORT_EVENTLOG`). Implement
|
||||
`IAlarmSource` over that interface so PLC alarms surface as OPC UA AC events.
|
||||
|
||||
**Open question (b) below** drives the implementation: does Beckhoff publish
|
||||
a managed wrapper, or do we hit AMS port 110 directly?
|
||||
|
||||
If a managed wrapper exists:
|
||||
- `Beckhoff.TwinCAT.Ads.TcEventLogger` (or similar) — subscribe via
|
||||
`EventLogger.AlarmRaised` event.
|
||||
|
||||
If not (likely — InfoSys docs lean on `TcCOM` C++ APIs):
|
||||
- Open a second `AdsClient` connection to port 110 via
|
||||
`_secondaryClient.Connect(netId, 110)`.
|
||||
- Use `AddDeviceNotificationAsync` on the alarm-list index group
|
||||
(`ADSIGRP_TCEVENTLOG_ALARMS`, exact constant TBD during spike).
|
||||
- Decode the binary event payload into `AlarmEvent` records (severity,
|
||||
source, message, time-of-occurrence, ack state).
|
||||
|
||||
**Files**:
|
||||
- New: `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAlarmSource.cs`
|
||||
— implements `IAlarmSource` (currently used by Galaxy / Wonderware).
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — declare
|
||||
`IAlarmSource` interface, delegate to the helper.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverOptions.cs` — new
|
||||
`bool EnableAlarms` (default `false` until production-validated).
|
||||
|
||||
**Beckhoff.TwinCAT.Ads API**: TBD pending spike. Falls back to raw
|
||||
`AdsClient.AddDeviceNotificationAsync` on port 110 if no managed wrapper.
|
||||
|
||||
**Test plan**:
|
||||
- Unit: fake event-logger feeds synthetic alarms; assert `IAlarmSource`
|
||||
surface raises events with correct shape.
|
||||
- Integration: TCBSD project gains an `Alarm.Raise(...)` call site on a GVL
|
||||
bool transition; new `[TwinCATFact]` subscribes via the driver, toggles the
|
||||
trigger, asserts the alarm appears in the source within 5 s.
|
||||
|
||||
**Effort**: L (4-5 days), most of which is the spike. If no managed wrapper
|
||||
exists, add another L (3-4 days) to implement the binary protocol decoder.
|
||||
**Deps**: spike answer to open question (b) — surface that as an explicit
|
||||
investigation PR before committing to the build.
|
||||
|
||||
**Docs / fixture / e2e**:
|
||||
- Docs: **new file** `docs/drivers/TwinCAT.md` (the existing
|
||||
`TwinCAT-Test-Fixture.md` is fixture-only) covering the alarm
|
||||
configuration surface — `EnableAlarms` option, AMS port 110 routing,
|
||||
severity / source / message decode, OPC UA AC mapping. Spike output
|
||||
goes to `docs/v3/twincat-eventlogger-spike.md` per open question (b).
|
||||
`docs/Driver.TwinCAT.Cli.md` gains a new `alarms` subcommand (subscribe
|
||||
+ print stream) mirroring the OPC UA Client CLI's `alarms` verb.
|
||||
`docs/drivers/TwinCAT-Test-Fixture.md` "Alarms / history" caveat
|
||||
removed; capability matrix gets `IAlarmSource = yes`.
|
||||
- Fixture (TCBSD PLC project, primary fixture-extension surface): add
|
||||
`PLC/POUs/FB_AlarmHarness.TcPOU` that calls `FB_TcLogEvent` (or
|
||||
equivalent TC3 EventLogger PLC API) on a 5 s tick, raising / clearing
|
||||
a known event class. New `PLC/GVLs/GVL_Alarms.TcGVL` exposes the
|
||||
trigger booleans the test toggles. `TwinCatProject/README.md` § new
|
||||
"Alarm scenarios" subsection documents the event class IDs + severity
|
||||
+ cleared-on transitions. The existing `ST_Alarm` DUT remains for
|
||||
PLC-level data; the EventLogger is the AC source.
|
||||
- Integration tests: new `TwinCATAlarmIntegrationTests.cs` —
|
||||
`Driver_raises_alarm_event_when_PLC_logs_event` `[TwinCATFact]`
|
||||
toggles the trigger via `WriteAsync`, asserts the alarm appears in
|
||||
`IAlarmSource.AlarmRaised` within 5 s. Includes a clear-event variant.
|
||||
Unit tests via fake event-logger feed synthetic alarms.
|
||||
- E2E: `scripts/e2e/test-twincat.ps1` gains a `Test-AlarmRoundTrip`
|
||||
step (toggle PLC trigger → assert event surfaces via OPC UA AC client)
|
||||
once the server-side wiring is in. Likely defers to a follow-up PR
|
||||
after the server-tier alarm rendering catches up.
|
||||
|
||||
## Documentation, fixture, and e2e impact
|
||||
|
||||
Consolidated view across all 12 PRs. The **TCBSD fixture PLC project**
|
||||
(`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/`) is
|
||||
the **primary fixture-extension surface** — it's a real TwinCAT XAE project
|
||||
committed object-by-object as `.TcGVL` / `.TcDUT` / `.TcPOU` files. Most PRs
|
||||
extend it by adding GVL variables, DUTs (structs / enums / aliases), or POUs
|
||||
(function blocks driving live data churn). The TCBSD VM at AmsNetId
|
||||
`41.169.163.43.1.1` on `10.100.0.128` is the deployment target (per memory
|
||||
entry `project_tcbsd_fixture.md`); the project bypasses the local Hyper-V/RTIME
|
||||
conflict (per `project_twincat_hyperv_conflict.md`) by running on ESXi.
|
||||
|
||||
### User docs touched
|
||||
|
||||
| PR | `docs/Driver.TwinCAT.Cli.md` | `docs/drivers/TwinCAT-Test-Fixture.md` | `docs/v3/twincat-backlog.md` | Other |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 LINT/ULINT | Data-types caveat removed | Bugs-caught entry #4 | — | — |
|
||||
| 1.2 TIME/DATE/DT/TOD | Native-type syntax + 4 examples | — | — | — |
|
||||
| 1.3 Bit-write | `write` example + RMW note | Bugs-caught entry #3 update | — | — |
|
||||
| 1.4 Arrays | New "Arrays" sub-section + read example | Coverage list bullet | — | — |
|
||||
| 1.5 ENUM/ALIAS | `browse` data-types rows | Coverage list bullet | — | — |
|
||||
| 2.1 Sum cmds | — | New "Performance" section | Closed-out perf bullet | — |
|
||||
| 2.2 Handles | Cache note in `read` / `subscribe` | Perf-section paragraph | — | — |
|
||||
| 2.3 Sym-version | — | Online-change-handling caveat dropped | — | — |
|
||||
| 3.1 MaxDelay | `--max-delay-ms` flag | Coalescing caveat updated | — | — |
|
||||
| 3.2 Diagnostics | `probe` health-symbols sub-section | New "Diagnostics" section | Cycle-time bullet closed | — |
|
||||
| 4.1 UDT | New top-level "UDT decomposition" section | Coverage list per-member | UDT-decomp gap removed | — |
|
||||
| 5.1 Alarms | New `alarms` subcommand | "Alarms" caveat removed | — | **New** `docs/drivers/TwinCAT.md`; **new** `docs/v3/twincat-eventlogger-spike.md` |
|
||||
|
||||
### TCBSD fixture PLC project changes
|
||||
|
||||
| PR | GVL changes | DUT changes | POU changes | README section |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 LINT/ULINT | `GVL_Primitives.vLargeCounter`, `vLargeCounterU` | — | — | "GVL_Primitives numeric seeds" |
|
||||
| 1.2 TIME/DATE/DT/TOD | `GVL_Primitives.dCurrentTime`, `tCycleDuration`, `dToday`, `tShiftStart` | — | — | "Type coverage" seed values |
|
||||
| 1.3 Bit-write | _(reuse `GVL_Primitives.vWord`)_ | — | — | — |
|
||||
| 1.4 Arrays | `GVL_Arrays.aReal2D : ARRAY[1..5,1..5] OF REAL` | — | — | "Array coverage" |
|
||||
| 1.5 ENUM/ALIAS | _(reuse `GVL_Enums`; new `currentSeverity`/`currentTemperature` instance vars)_ | — | — | "Integration-test contract" entry |
|
||||
| 2.1 Sum cmds | **`GVL_Perf.aTags : ARRAY[1..1000] OF DINT`** | — | New `FB_PerfChurn` driving rotating writes | New "Performance scenarios" subsection |
|
||||
| 2.2 Handles | _(reuse `GVL_Perf.aTags`)_ | — | — | — |
|
||||
| 2.3 Sym-version | _(no schema change; manual online-change drill)_ | — | — | New "Online-change test scenario" |
|
||||
| 3.1 MaxDelay | _(reuse `GVL_Fixture.nCounter` 100 Hz driver)_ | — | — | One-line note in "Required project state" |
|
||||
| 3.2 Diagnostics | _(reads system GVLs `_AppInfo`, `_TaskInfo[1]`)_ | — | — | Probe-symbols callout |
|
||||
| 4.1 UDT | _(reuse `GVL_Plant`; possibly grow `aLargeAlarms : ARRAY[1..2000] OF ST_AlarmRecord`)_ | New `ST_NestedFlags`, `ST_RecursiveCap`, `ST_AlarmRecord` | — | "Complex hierarchy" edge-cases |
|
||||
| 5.1 Alarms | New `GVL_Alarms` (trigger booleans) | — | New `FB_AlarmHarness` calling `FB_TcLogEvent` | New "Alarm scenarios" |
|
||||
|
||||
### Integration test additions
|
||||
|
||||
All new tests gate on `[TwinCATFact]` / `[TwinCATTheory]` against
|
||||
`TWINCAT_TARGET_NETID`. Most ship in `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.
|
||||
IntegrationTests/TwinCAT3SmokeTests.cs`; PR 5.1 introduces a new
|
||||
`TwinCATAlarmIntegrationTests.cs`. The existing 30-case suite grows to
|
||||
roughly **45 cases** end-of-plan, plus a perf-tier guarded behind
|
||||
`TWINCAT_PERF=1`.
|
||||
|
||||
### E2E scripts
|
||||
|
||||
`scripts/e2e/test-twincat.ps1` is the single TwinCAT e2e bridge today; it's
|
||||
gated behind `TWINCAT_TRUST_WIRE=1` (see task #221 — CI fixture). The plan
|
||||
intentionally **does not change** the canonical bridge for most PRs because
|
||||
the bridge exercises one DINT counter through the OPC UA server, and that
|
||||
path stays correct. PRs 1.2 (DT bridge), 1.4 (array bridge), 4.1 (UDT
|
||||
bridge), 5.1 (alarm round-trip) each list speculative e2e extensions but
|
||||
they're explicitly marked as follow-ups gated on server-side rendering
|
||||
catching up.
|
||||
|
||||
## Skip-rated items (for context)
|
||||
|
||||
These are intentionally not built. Listed for future-reader completeness so
|
||||
nobody re-invests effort that was already triaged:
|
||||
|
||||
| # | Gap | Why skip |
|
||||
|---|---|---|
|
||||
| 9 | Multi-target / multi-route AMS gateway | Per-device config in `TwinCATDriverOptions.Devices` already supports N targets |
|
||||
| 10 | Secure ADS / ADS-over-TLS | Significant work — TC3.1 build 4024+ feature, host-router-level config; defer |
|
||||
| 11 | Route credential management | Host-level AMS router responsibility (`StaticRoutes.xml`); not driver scope |
|
||||
| 12 | NC-axis / CNC channel / EtherCAT slave I/O | Specialty; system-symbol filter actively drops `Mc_*` (`TwinCATSystemSymbolFilter.cs:28`) |
|
||||
| 13 | System-service ports (200/10000) | Niche operational tooling; user-runtime ports cover real use cases |
|
||||
| 15 | PLC RPC / method invocation | Niche; design-heavy; no demand signal yet |
|
||||
| 16 | Per-PLC-runtime auto-discover | Cosmetic; manual port config in options works |
|
||||
| 20 | File-system access via ADS (FOPEN/FREAD) | Niche; out of scope |
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **(a) TMC parsing — separate library or embedded?**
|
||||
Phase 4 ships the **online type-walker** path which uses
|
||||
`Beckhoff.TwinCAT.Ads.TypeSystem.SymbolLoaderFactory` and needs a live
|
||||
runtime. If a future use case needs offline discovery (e.g. address-space
|
||||
pre-bake at build time without a reachable PLC), do we:
|
||||
- vendor a TMC-XML parser into this driver, or
|
||||
- build a separate `ZB.MOM.WW.OtOpcUa.Tooling.TwinCAT` CLI that emits a
|
||||
pre-baked tag manifest?
|
||||
The latter cleanly separates build-time tooling from runtime driver code
|
||||
and matches how Galaxy.Host is split. Decision deferred until demand
|
||||
appears; recommend the CLI route when it does.
|
||||
|
||||
2. **(b) Beckhoff TC3 EventLogger NuGet — published, or AMS port 110 raw?**
|
||||
Need to spike against the current `Beckhoff.TwinCAT.Ads` v6 NuGet API
|
||||
surface. Beckhoff InfoSys lists a `Tc3_EventLogger` PLC library and a
|
||||
TcCOM C++ API but the .NET surface is thinner. PR 5.1 starts with a
|
||||
one-day spike documented as `docs/v3/twincat-eventlogger-spike.md` before
|
||||
committing to the implementation path.
|
||||
|
||||
3. **(c) Symbol-version invalidation event details**
|
||||
PR 2.3 needs the exact index-group constant and notification semantics for
|
||||
the symbol-version counter. `AdsReservedIndexGroup.SymbolVersion` (0xF008)
|
||||
is the working hypothesis but the field on the v6 enum needs verification
|
||||
— the older `TwinCAT.Ads.AdsReservedIndexGroup` enum had different naming.
|
||||
Beckhoff InfoSys `tcadscommon/tcadscommon_indexgroups` is the reference;
|
||||
confirm during the PR 2.3 spike. Fallback: poll the version counter at
|
||||
probe-loop cadence and treat any change as an invalidation.
|
||||
|
||||
## References
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSystemSymbolFilter.cs`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATAmsAddress.cs`
|
||||
- `docs/featuregaps.md` — TwinCAT (Beckhoff ADS) section
|
||||
- `docs/v3/twincat-backlog.md` — deferred items (TC2, multi-hop, lab IPC)
|
||||
- `docs/drivers/TwinCAT-Test-Fixture.md` — TCBSD + XAR fixture details
|
||||
- Beckhoff InfoSys: <https://infosys.beckhoff.com/english.php?content=../content/1033/tcadsdll2/117571083.html> (Sum commands)
|
||||
- Beckhoff InfoSys: <https://infosys.beckhoff.com/english.php?content=../content/1033/tcadsnetref/7313319051.html> (NotificationSettings)
|
||||
- Beckhoff GitHub: <https://github.com/Beckhoff/TC3-AdsClient-Csharp>
|
||||
Reference in New Issue
Block a user