Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77c8a3569 | |||
| 5184a2e107 | |||
| 88ce8df099 | |||
| 6a01358b9a | |||
| d32d89c340 | |||
| adce27e7fa | |||
| 09a401b881 | |||
| 53ede679c3 | |||
| e08855fb9d | |||
| 90bdaa4437 | |||
| 813e445936 | |||
| 9d430bee62 | |||
| 1d0989d3b4 | |||
| 71ccccef7c | |||
| cf9ce91e51 | |||
| 9227d03ca8 | |||
| 115a43dd47 | |||
| 51c8c7f30b | |||
| b0046d6959 | |||
| c232a3ce67 | |||
| a107a1a8b9 | |||
| ba89d6d0ff | |||
| 23cd47d54b | |||
| dfbcb0e7f0 | |||
| fa0e40796f | |||
| 13dd9fcd70 | |||
| 9a67ed918a | |||
| a127954cab | |||
| 1bfc1722b3 | |||
| 4fe0af3693 | |||
| 908bd7ba4a | |||
| 5ba9f1be6f | |||
| ac0a284055 | |||
| cf43f8d0ab | |||
| d3aaa4a411 | |||
| 1990d21733 | |||
| b13cf5c6e8 | |||
| 79e30d1ead | |||
| 3ca8ddf6f7 | |||
| c0729ae5c0 | |||
| 992ffe5620 | |||
| 18742ea92b | |||
| e36b920113 | |||
| 3b92b7cc83 | |||
| a0b5095b3b | |||
| 394558e6c2 | |||
| c3a2b0f773 | |||
| 123ddc3f40 | |||
| 0f38f48679 | |||
| 0e93587b1c | |||
| 4cc0215bb4 | |||
| 4b9bcddbcc | |||
| 2193d9f2e4 | |||
| 8b7ea3213d | |||
| 240d7aa8aa | |||
| 95295210b0 | |||
| e3155fbbf5 | |||
| 47d148daf9 | |||
| ee12568cab | |||
| d5bd4226ee | |||
| 8d9155682d | |||
| 767e7031b3 | |||
| 64ec7aa43a | |||
| 6d7a458c4d | |||
| cd0157a3b8 | |||
| 6917d8805d | |||
| 8538fb798b | |||
| 31a98a1bf5 | |||
| 3fdf24659f | |||
| e4e313cf1c | |||
| 2afef00eaa | |||
| b245f380b1 | |||
| c164ec3da1 | |||
| 2589774480 | |||
| 163dd7ab5c | |||
| 1ae26675ac | |||
| 92ba120964 | |||
| 07a4a5ff5e | |||
| 2409d05a28 | |||
| 033b3700c4 | |||
| ff62b62a55 | |||
| a13ae926a8 | |||
| f79d13e2d8 | |||
| 36abd871a1 | |||
| 420692b6e5 | |||
| 211c6ea6d6 | |||
| a856d61510 | |||
| abf3116bd4 | |||
| 4ec01bdfc1 | |||
| d421487bcd | |||
| e27eb77187 | |||
| f2a9ee5d93 | |||
| 7980b34692 | |||
| fcc7ed698e | |||
| a6370a26f8 | |||
| 768fd87774 | |||
| cacc30a60d | |||
| 5629f3698d | |||
| 4a8c9badce | |||
| ded4c41798 | |||
| 650e27075f | |||
| 726d6d198e | |||
| 132e7b63f6 | |||
| 13bd9820b0 | |||
| 1d90bf3611 | |||
| e3fea6e409 | |||
| 20be5416b9 | |||
| a5a8710af5 | |||
| 47e9cd56ef | |||
| f22db5d801 | |||
| 2f38b5b285 | |||
| d677ac7ad3 | |||
| 20100c36ad | |||
| eed6617784 | |||
| 8d0f60ec51 | |||
| e787aa572b |
@@ -55,6 +55,28 @@ about OtOpcUa changes here — remote/push status, the driver set, the Galaxy da
|
||||
shared-lib consumption, or per-project commands — update the **OtOpcUa entry in
|
||||
`../scadaproj/CLAUDE.md`** in the same change so the index never drifts from this repo.
|
||||
|
||||
**The index edit belongs on `scadaproj`'s `main`, and must be pushed.** `scadaproj` is a
|
||||
separate repo with its own checkout, so committing there inherits whatever branch it
|
||||
happens to be on — which is usually *not* `main` and is often an unrelated feature branch
|
||||
with no upstream. That silently drifts the index for as long as that branch stays
|
||||
unmerged, which is the exact failure the rule above exists to prevent. Do this explicitly:
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/scadaproj
|
||||
git rev-parse --abbrev-ref HEAD # note it, to restore afterwards
|
||||
git stash list # never commit onto a dirty unrelated branch
|
||||
git checkout main && git pull
|
||||
# …edit the OtOpcUa entry in CLAUDE.md…
|
||||
git commit -am "docs(index): <what changed about OtOpcUa>"
|
||||
git push origin main
|
||||
git checkout - # restore the original branch
|
||||
```
|
||||
|
||||
Observed 2026-07-27: the `Sql` poll driver's index entry had sat unpushed on an unrelated
|
||||
feature branch since 2026-07-24 because it was committed onto the current checkout, so the
|
||||
index disagreed with reality for three days despite this rule. If you find index commits
|
||||
stranded on a feature branch, cherry-pick them onto `main` rather than merging that branch.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Data Flow
|
||||
@@ -131,7 +153,71 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double,
|
||||
|
||||
### Change Detection
|
||||
|
||||
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys. The server's `DriverHost` consumes the signal and rebuilds the address space.
|
||||
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys.
|
||||
|
||||
**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27).
|
||||
`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is
|
||||
load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around
|
||||
the same driver object. A raise is recorded and rides the driver-health snapshot
|
||||
(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a
|
||||
**"re-browse" chip**.
|
||||
|
||||
⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw
|
||||
tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and
|
||||
commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact
|
||||
records.
|
||||
|
||||
Two gotchas if you touch this:
|
||||
|
||||
- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in
|
||||
that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the
|
||||
operator. Pinned by `DriverInstanceActorRediscoverySignalTests`.
|
||||
- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on
|
||||
every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests
|
||||
use a stub with stable health for exactly this reason.
|
||||
|
||||
**#507 is closed as superseded, and the injection path is deleted.** Its retained code read
|
||||
`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3
|
||||
(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log
|
||||
line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
|
||||
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
|
||||
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop
|
||||
(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18
|
||||
`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft
|
||||
(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw`
|
||||
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
|
||||
the actor.
|
||||
|
||||
**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor`
|
||||
re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers
|
||||
against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and
|
||||
FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their
|
||||
only way to notice a PLC re-download.
|
||||
|
||||
⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting
|
||||
`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo
|
||||
authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a
|
||||
tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse
|
||||
than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection
|
||||
legitimately means "nothing authored, so everything discovered is new".
|
||||
|
||||
⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit
|
||||
their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker
|
||||
shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear
|
||||
missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then
|
||||
reports only what it can see and **says so** in the operator message rather than implying a clean bill of
|
||||
health. **MTConnect is currently the only driver where both directions are detectable** — its stream is
|
||||
built purely from the Agent's probe model.
|
||||
|
||||
Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise
|
||||
report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an
|
||||
**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that
|
||||
resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit.
|
||||
|
||||
⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
|
||||
nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
|
||||
confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked
|
||||
as Gitea **#521**.
|
||||
|
||||
## mxaccessgw
|
||||
|
||||
@@ -168,13 +254,29 @@ central SQL Server.
|
||||
> recipes (S7 blackhole, GLAuth outage, HistorianGateway LiveIntegration), the integration-test
|
||||
> harness, the docker-dev rig, and the deploy setup. Start there; the sections below + `docs/v2/dev-environment.md` carry the detail.
|
||||
|
||||
> **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. The driver fixture compose files under `tests/.../Docker/docker-compose.yml` now carry a `project: lmxopcua` label on every service. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28).
|
||||
> **Migrated 2026-04-28**: Docker config + host moved off this dev VM (DESKTOP-6JL3KKO) onto the shared Linux Docker host (`DOCKER`, 10.100.0.35) so the dev VM could shed WSL2/Hyper-V and have its GPU re-attached via ESXi passthrough. Docker Desktop is no longer installed here. All checked-in `appsettings.json` defaults, fixture-class default endpoints, and `e2e-config.sample.json` were rewritten to target `10.100.0.35`. See `docs/v2/dev-environment.md` for the full rewrite (header dated 2026-04-28).
|
||||
|
||||
> ⚠️ **The `project: lmxopcua` label is aspirational, not universal.** This note used to claim every fixture compose file carries it. As of 2026-07-24 only the **MQTT** fixture actually does — `grep -rn "labels:" tests --include=*.yml` matches `Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` and nothing else. So `docker ps --filter label=project=lmxopcua` returns the MQTT fixture alone, not the fleet. Add the label when you touch an older fixture; until then enumerate by container name.
|
||||
|
||||
Docker workloads run on a shared Linux host at **`10.100.0.35`** — not on this VM. Stacks live at `/opt/otopcua-<driver>/` on the host and carry the `project=lmxopcua` label so they're discoverable via `docker ps --filter label=project=lmxopcua`.
|
||||
|
||||
**`docker -H ssh://...` does NOT work from this VM.** Windows OpenSSH ↔ docker.exe stdio bridging hangs (`docker system dial-stdio` runs server-side but no API data flows). Use the helper below — it SSHes into the docker host and runs `docker compose` server-side.
|
||||
**`docker -H ssh://...` does NOT work from the Windows VM.** Windows OpenSSH ↔ docker.exe stdio bridging hangs (`docker system dial-stdio` runs server-side but no API data flows). Use the helper below — it SSHes into the docker host and runs `docker compose` server-side.
|
||||
|
||||
**Use `lmxopcua-fix.ps1` (in `~/bin`) to control fixtures from this VM:**
|
||||
> **On macOS there is no `lmxopcua-fix` — drive the host over SSH directly.** The helper is a Windows-VM
|
||||
> script; `~/bin` is empty on the Mac and the commands below will not resolve. Use passwordless SSH
|
||||
> (and `rsync` in place of `sync`), which is what `infra/README.md` §1 documents as the Mac path:
|
||||
>
|
||||
> ```bash
|
||||
> ssh dohertj2@10.100.0.35 'docker ps --format "{{.Names}}\t{{.Status}}"'
|
||||
> ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose up -d'
|
||||
> rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/ \
|
||||
> dohertj2@10.100.0.35:/opt/otopcua-mqtt/ # the `sync` step, by hand
|
||||
> ```
|
||||
>
|
||||
> A local container runtime *does* exist on the Mac (OrbStack), so the `docker-dev/` rig itself runs
|
||||
> locally there — it is only the shared **fixtures** that live on `10.100.0.35`.
|
||||
|
||||
**Use `lmxopcua-fix.ps1` (in `~/bin`) to control fixtures from the Windows VM:**
|
||||
|
||||
```powershell
|
||||
lmxopcua-fix ls # list all lmxopcua-tagged containers on the host
|
||||
@@ -195,6 +297,16 @@ lmxopcua-fix sync modbus # rsync this repo's tests/.../Docker/
|
||||
- AB CIP: `10.100.0.35:44818` (`AB_SERVER_ENDPOINT`)
|
||||
- S7: `10.100.0.35:1102` (`S7_SIM_ENDPOINT`)
|
||||
- OPC UA reference (opc-plc): `opc.tcp://10.100.0.35:50000` (`OPCUA_SIM_ENDPOINT`)
|
||||
- MQTT (Mosquitto, **auth on both listeners — no anonymous fallback**): `10.100.0.35:8883` TLS+auth
|
||||
(`MQTT_FIXTURE_ENDPOINT`) · `10.100.0.35:1883` plaintext-but-authenticated (`MQTT_FIXTURE_PLAIN_ENDPOINT`).
|
||||
Also needs `MQTT_FIXTURE_USERNAME` / `MQTT_FIXTURE_PASSWORD`, and `MQTT_FIXTURE_CA_CERT` to pin the
|
||||
fixture CA (`scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt`). A co-running publisher loops
|
||||
JSON under `otopcua/fixture/…`.
|
||||
- MQTT **Sparkplug B**: the same broker, plus the `sparkplug` compose profile's `otopcua-sparkplug-sim`
|
||||
(project-owned C# edge-node simulator) — group **`OtOpcUaSim`**, edge nodes **`EdgeA`**/**`EdgeB`**,
|
||||
`EdgeA` device **`Filler1`**, ~2 s cadence. It subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}` and
|
||||
re-births on request. **Births are never retained**, so `docker restart otopcua-sparkplug-sim` is how
|
||||
you force a fresh one while a browse window is open.
|
||||
|
||||
Override any endpoint via the env var to point at a real PLC. The local OtOpcUa server runs on this VM at `opc.tcp://localhost:4840` — **that's not on the docker host**.
|
||||
|
||||
@@ -221,7 +333,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
|
||||
|
||||
**Two corrections landed 2026-07-21 — both were total-outage or wrong-node defects, and both are worth knowing before touching this area.**
|
||||
|
||||
- **Downing is `auto-down`, not `keep-oldest`.** `Cluster:SplitBrainResolverStrategy` (default `auto-down`) selects the provider via `ServiceCollectionExtensions.BuildDowningHocon`; an unknown value fails host start. A two-node pair running the previous `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node**: Akka's `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs *itself* and exits. `down-if-alone` is a 3+-node feature. The accepted trade for `auto-down` is dual-active during a genuine network partition. **Its live gate is still open** — the pathology only appears 1-vs-1 and docker-dev is a single six-node mesh, so the crash-the-oldest drill is deferred to the per-cluster mesh work.
|
||||
- **Downing is `auto-down`, not `keep-oldest`.** `Cluster:SplitBrainResolverStrategy` (default `auto-down`) selects the provider via `ServiceCollectionExtensions.BuildDowningHocon`; an unknown value fails host start. A two-node pair running the previous `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node**: Akka's `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs *itself* and exits. `down-if-alone` is a 3+-node feature. The accepted trade for `auto-down` is dual-active during a genuine network partition. **Its live gate is CLOSED** — Phase 7 drill D4 (2026-07-24) ran the 1-vs-1 crash-the-oldest survival drill on the three-mesh docker-dev rig and every survivor stayed Up, never self-downing (`c50ebcf7`; `docs/plans/2026-07-24-mesh-phase7-failover-drills.md`).
|
||||
- **The Primary is the oldest Up `driver` member, not the role leader.** `RedundancyStateActor` previously used `ClusterState.RoleLeader("driver")` (lowest *address*), while `ClusterSingletonManager` places singletons on the *oldest*. They agree only on a freshly-formed cluster and diverge after any restart, so the Primary-gated data plane (writes, alarm acks, alerts emit, alarm-history drain) could enable on a node not hosting the singletons. `NodeRedundancyState.IsRoleLeaderForDriver` was renamed **`IsDriverPrimary`**.
|
||||
|
||||
**A third bootstrap gap closed 2026-07-22 — downing was never the whole story, and the fix changed twice.** Even under `auto-down`, a node cold-starting while its peer was dead **never came Up**: Akka runs `FirstSeedNodeProcess` — the only bootstrap path that can form a *new* cluster when no peer answers `InitJoin` — exclusively when `seed-nodes[0]` is the node’s own address; every other node runs `JoinSeedNodeProcess` and retries `InitJoin` forever. **`Cluster:SeedNodes` is therefore ORDERED: every node that is one of its own seeds lists ITSELF first, partner second** (docker-dev `central-2` was swapped; site nodes list only `central-1` and are legitimately not seeds). `AkkaClusterOptionsValidator` (`AddValidatedOptions`/`ValidateOnStart` in `AddOtOpcUaCluster`) enforces it at boot — **conditionally** (self must be entry 0 only *if* self is in the list, or every site node would refuse to start), matching on `PublicHostname`+`Port`, never the `0.0.0.0` bind address. The earlier fix — a `Cluster:SelfFormAfter` timer calling `Cluster.Join(SelfAddress)` — is **deleted**: it sat outside Akka’s join handshake, so it could not tell "no seed answered" from "a seed answered and the join is in flight", and it islanded a manually-failed-over node live before a TCP reachability guard patched that one shape. Pinned by `SelfFirstSeedBootstrapTests` (real in-process clusters, incl. a falsifiability control proving peer-first ordering never forms) + `AkkaClusterOptionsValidatorTests`. See `docs/Redundancy.md` §"Bootstrap: self-first seed ordering".
|
||||
@@ -332,7 +444,7 @@ Flip only the site nodes with `OTOPCUA_CONFIG_MODE=FetchAndCache` at `docker com
|
||||
|
||||
## Live telemetry transport (`Telemetry` / `TelemetryDial`)
|
||||
|
||||
Per-cluster mesh **Phase 5** (code-complete on `feat/mesh-phase5`, live gate pending) added a second
|
||||
Per-cluster mesh **Phase 5** (merged `35552a96`; live gate PASSED `f134935f`) added a second
|
||||
transport for the four live-observability channels the AdminUI's `/alerts`, `/script-log`, and
|
||||
`/hosts` panels feed on, selected by `Telemetry:Mode` (node/serve side) and `TelemetryDial:Mode`
|
||||
(central/dial side): `Dps` — the four existing DPS SignalR bridges, **the default**, today's
|
||||
@@ -442,7 +554,8 @@ unaffected (it keeps ConfigDb via its admin role). Consequences:
|
||||
- **Scripted-alarm Part 9 condition state moved to LocalDb.** `LocalDbAlarmConditionStateStore`
|
||||
(replicated `alarm_condition_state` table, pair-local like the Phase-2 alarm S&F buffer) replaces
|
||||
`EfAlarmConditionStateStore` on every driver-role node, fused central included. The DB-backed
|
||||
`ScriptedAlarmState` table is now unused (dropping it is a deferred follow-up, tracked as Task 9).
|
||||
`ScriptedAlarmState` table **was dropped** (Task 9, `3a590a0c`, migration
|
||||
`20260723183050_DropScriptedAlarmStateTable`); `EfAlarmConditionStateStore` is deleted.
|
||||
- **Central persists deploy acks.** `DriverHostActor` no longer writes `NodeDeploymentState` to SQL on
|
||||
a driver-only node — `ConfigPublishCoordinator.PersistNodeAck` (already fed by every ApplyAck) is
|
||||
the system of record.
|
||||
@@ -451,8 +564,8 @@ unaffected (it keeps ConfigDb via its admin role). Consequences:
|
||||
`OtOpcUaGroupRoleMapper` falls back to the `Security:Ldap:GroupToRole` appsettings baseline only —
|
||||
the same rows a driver node never received via config either, so this is not a regression.
|
||||
|
||||
Code-complete on `feat/mesh-phase4`; the table-drop (Task 9) and the live gate (Task 10) are still
|
||||
open. See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
|
||||
Merged to master (`26fad75c`); **live gate PASSED** (`1281aebf` — driver nodes run ConfigDb-free) and
|
||||
Task 9 landed (`3a590a0c`). See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
|
||||
`docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4.
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
@@ -75,6 +75,20 @@
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<!--
|
||||
MQTT client for the Mqtt / Sparkplug B driver. MQTTnet v5 is the first line shipping a
|
||||
net10.0 TFM (lib/net10.0, and an EMPTY net10.0 dependency group — zero transitive
|
||||
packages, so it cannot participate in a diamond under this repo's deliberately-OFF
|
||||
CentralPackageTransitivePinningEnabled). Sparkplug B payloads are hand-rolled rather than
|
||||
taken from SparkplugNet, whose newest release (1.3.10) still pins MQTTnet 4.3.6.1152 and
|
||||
ships net6.0/net8.0 only.
|
||||
-->
|
||||
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
||||
<!-- Driver.MTConnect carries NO backend NuGet. The TrakHound MTConnect.NET-Common/-HTTP pins
|
||||
Task 0 added were removed in Task 7 (2026-07-24): neither package can parse an MTConnect
|
||||
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
|
||||
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
|
||||
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
|
||||
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
|
||||
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OtOpcUa
|
||||
|
||||
OPC UA server (.NET 10 AnyCPU) that exposes a fleet of industrial drivers as a single OPC UA address space. Drivers ship in-process for AVEVA System Platform Galaxy (via the sibling `mxaccessgw` repo), Modbus TCP, Siemens S7, Allen-Bradley CIP (ControlLogix / CompactLogix), Allen-Bradley Legacy (SLC 500 / MicroLogix), Beckhoff TwinCAT (ADS), FANUC FOCAS, and OPC UA Client (gateway).
|
||||
OPC UA server (.NET 10 AnyCPU) that exposes a fleet of industrial drivers as a single OPC UA address space. Drivers ship in-process for AVEVA System Platform Galaxy (via the sibling `mxaccessgw` repo), Modbus TCP, Siemens S7, Allen-Bradley CIP (ControlLogix / CompactLogix), Allen-Bradley Legacy (SLC 500 / MicroLogix), Beckhoff TwinCAT (ADS), FANUC FOCAS, OPC UA Client (gateway), and MQTT (broker subscribe; Sparkplug B in progress).
|
||||
|
||||
A cross-platform client stack (.NET 10) — shared library, CLI, and Avalonia desktop app — connects to any OPC UA server.
|
||||
|
||||
@@ -15,7 +15,7 @@ A cross-platform client stack (.NET 10) — shared library, CLI, and Avalonia de
|
||||
| address space + capability fan-out|
|
||||
+-------------------------------------+
|
||||
| | | | | | | |
|
||||
Galaxy Modbus S7 AbCip AbLeg TwinCAT FOCAS OpcUaClient
|
||||
Galaxy Modbus S7 AbCip AbLeg TwinCAT FOCAS OpcUaClient MQTT
|
||||
|
|
||||
v
|
||||
mxaccessgw (sibling repo, gRPC)
|
||||
@@ -91,7 +91,7 @@ See [docs/Client.CLI.md](docs/Client.CLI.md) and [docs/Client.UI.md](docs/Client
|
||||
|---|---|
|
||||
| Driver specs (per-driver capability surface, config, addressing) | [docs/v2/driver-specs.md](docs/v2/driver-specs.md) |
|
||||
| Galaxy driver | [docs/drivers/Galaxy.md](docs/drivers/Galaxy.md) |
|
||||
| Modbus / S7 / AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient | [docs/drivers/](docs/drivers/) |
|
||||
| Modbus / S7 / AbCip / AbLegacy / TwinCAT / FOCAS / OpcUaClient / MQTT | [docs/drivers/](docs/drivers/) |
|
||||
| Galaxy parity rig (mxaccessgw setup) | [docs/v2/Galaxy.ParityRig.md](docs/v2/Galaxy.ParityRig.md) |
|
||||
| Galaxy performance + tracing | [docs/v2/Galaxy.Performance.md](docs/v2/Galaxy.Performance.md) |
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
|
||||
@@ -45,6 +47,9 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/Driver CLIs/">
|
||||
<Project Path="src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj" />
|
||||
@@ -96,6 +101,8 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
|
||||
@@ -110,6 +117,9 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.SparkplugSimulator.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/Driver CLIs/">
|
||||
<Project Path="tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj" />
|
||||
|
||||
@@ -9,78 +9,110 @@
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) — must FAIL at f6eaa267",
|
||||
"subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) \u2014 must FAIL at f6eaa267",
|
||||
"status": "completed",
|
||||
"blockedBy": [0]
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4",
|
||||
"status": "completed",
|
||||
"blockedBy": [0, 1]
|
||||
"blockedBy": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)",
|
||||
"status": "completed",
|
||||
"blockedBy": [1, 2]
|
||||
"blockedBy": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)",
|
||||
"status": "completed",
|
||||
"blockedBy": [3]
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green",
|
||||
"status": "completed",
|
||||
"blockedBy": [4]
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) — must FAIL",
|
||||
"subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) \u2014 must FAIL",
|
||||
"status": "completed",
|
||||
"blockedBy": [0]
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)",
|
||||
"status": "completed",
|
||||
"blockedBy": [6]
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)",
|
||||
"status": "completed",
|
||||
"blockedBy": [7]
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "RED->GREEN: probe marks handle dead under the gate (T8 persistent probe fault + T9 probe timeout; ProbeLoopAsync inner catch restructure, STAB-15b)",
|
||||
"status": "completed",
|
||||
"blockedBy": [8]
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync <remarks> (STAB-8 S7 part, note-only)",
|
||||
"status": "completed",
|
||||
"blockedBy": [3]
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Env-gated live connect-timeout outage test (S7_TIMEOUT_OUTAGE_START_CMD/STOP_CMD blackhole design; skips cleanly offline)",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"note": "env-gated; runs in serial live pass (integration suite; serialized heavy pass). Test authored + committed; verified it SKIPs cleanly offline.",
|
||||
"blockedBy": [5]
|
||||
"blockedBy": [
|
||||
5
|
||||
],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-15 \u2014 S7 blackhole run via `docker pause` on the 10.100.0.35 s7_1500 fixture. Found + fixed a real read-leg wall-clock gap (PR #453). STATUS.md 'docker-dev live-/run pass'."
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Close-out: whole-suite sweep + update archreview STATUS.md and 05 prior-finding table (STAB-14/15 FIXED, STAB-8 seam noted)",
|
||||
"status": "completed",
|
||||
"note": "S7 unit 246/246 green; S7 CLI 48/49 (1 pre-existing unrelated comment-scan failure, see deviations); solution BUILD clean (0 errors). Whole-solution dotnet test NOT run per controller memory-constraint (integration suites deferred to serial heavy pass).",
|
||||
"blockedBy": [5, 7, 8, 9, 10, 11]
|
||||
"blockedBy": [
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-13"
|
||||
"lastUpdated": "2026-07-27"
|
||||
}
|
||||
|
||||
@@ -1,25 +1,170 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-02-resilience-config-hardening-plan.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls — ResilienceConfigBrickTests, expect 2 failures on f6eaa267", "status": "completed", "blockedBy": [] },
|
||||
{ "id": 1, "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": 2, "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 3, "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 4, "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", "status": "completed", "blockedBy": [2, 3] },
|
||||
{ "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "completed", "blockedBy": [1] },
|
||||
{ "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "completed", "blockedBy": [2] },
|
||||
{ "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "completed", "blockedBy": [6] },
|
||||
{ "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "completed", "blockedBy": [7] },
|
||||
{ "id": 9, "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", "status": "completed", "blockedBy": [7, 5] },
|
||||
{ "id": 10, "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", "status": "completed", "blockedBy": [2, 6] },
|
||||
{ "id": 11, "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", "status": "completed", "blockedBy": [5, 10] },
|
||||
{ "id": 12, "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", "status": "completed", "blockedBy": [10, 11] },
|
||||
{ "id": 13, "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", "status": "completed", "blockedBy": [11] },
|
||||
{ "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "completed", "blockedBy": [13] },
|
||||
{ "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "deferred-live", "note": "docker-dev :9200 live-/run; serial live pass", "blockedBy": [14] },
|
||||
{ "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "completed", "blockedBy": [3] },
|
||||
{ "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "completed", "blockedBy": [16] },
|
||||
{ "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "deferred-live", "note": "integration/full sweep; serialized heavy pass", "blockedBy": [4, 8, 9, 12, 15, 17] }
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls \u2014 ResilienceConfigBrickTests, expect 2 failures on f6eaa267",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false \u2014 RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
10,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)",
|
||||
"status": "completed",
|
||||
"note": "docker-dev :9200 live-/run; serial live pass",
|
||||
"blockedBy": [
|
||||
14
|
||||
],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 range-validation banner verified on the docker-dev Modbus driver's Resilience overrides (`timeoutSeconds:0`). STATUS.md 'docker-dev live-/run pass'."
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md",
|
||||
"status": "completed",
|
||||
"note": "integration/full sweep; serialized heavy pass",
|
||||
"blockedBy": [
|
||||
4,
|
||||
8,
|
||||
9,
|
||||
12,
|
||||
15,
|
||||
17
|
||||
],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 S-8 verified: Write and AlarmAcknowledge RETRIES cells disabled ('never'). STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-12"
|
||||
"lastUpdated": "2026-07-27"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"tasks": [
|
||||
{
|
||||
"id": "R2-03-T1",
|
||||
"subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
|
||||
"subject": "S13 stale-Good repro test at host level (RED \u2014 must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
{
|
||||
"id": "R2-03-T9",
|
||||
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
|
||||
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes \u2014 identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"R2-03-T3"
|
||||
@@ -86,7 +86,8 @@
|
||||
"R2-03-T8",
|
||||
"R2-03-T10"
|
||||
],
|
||||
"note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)"
|
||||
"note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)",
|
||||
"openNote": "STILL OPEN \u2014 live-blocked on a data-less rig: every materialised node reads Bad_WaitingForInitialData because no driver data flows, so a VT cannot be staged Good->Bad. Needs a reachable driver fixture feeding the VT. Unit-verified meanwhile."
|
||||
},
|
||||
{
|
||||
"id": "R2-03-T12",
|
||||
@@ -95,8 +96,9 @@
|
||||
"blockedBy": [
|
||||
"R2-03-T11"
|
||||
],
|
||||
"note": "docker-dev live-/run; serial live pass"
|
||||
"note": "docker-dev live-/run; serial live pass",
|
||||
"openNote": "STILL OPEN \u2014 same blocker as T11. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-13"
|
||||
"lastUpdated": "2026-07-27"
|
||||
}
|
||||
|
||||
@@ -1,22 +1,146 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] },
|
||||
{ "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] },
|
||||
{ "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "completed", "blockedBy": ["T12", "T13"], "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." },
|
||||
{ "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "deferred-live", "blockedBy": ["T14"], "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate — controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin)." },
|
||||
{ "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "completed", "blockedBy": ["T15"] }
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T3",
|
||||
"T6",
|
||||
"T7",
|
||||
"T8",
|
||||
"T9",
|
||||
"T10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
"subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T14",
|
||||
"subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T12",
|
||||
"T13"
|
||||
],
|
||||
"note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint."
|
||||
},
|
||||
{
|
||||
"id": "T15",
|
||||
"subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T14"
|
||||
],
|
||||
"note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate \u2014 controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin).",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 (partial by design) \u2014 every config/read page renders under the all-roles multi-role-test session, so named-policy gating does not over-gate a legitimate admin. The DENY direction is unobservable on docker-dev (DisableLogin=true), as documented."
|
||||
},
|
||||
{
|
||||
"id": "T16",
|
||||
"subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T15"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-06-serverhistorian-failfast-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
|
||||
"subject": "RED: misconfig repro test \u2014 HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
|
||||
"subject": "GREEN: adapter guard \u2014 Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)",
|
||||
"subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType \u2014 0.2.0 proto3-optional presence witness; pin, no RED phase)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
@@ -89,12 +89,13 @@
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T9",
|
||||
"T11"
|
||||
],
|
||||
"note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker → real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 → gateway EnsureTags threw ArgumentOutOfRangeException → ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float→Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException — Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean→Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md."
|
||||
"note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker \u2192 real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 \u2192 gateway EnsureTags threw ArgumentOutOfRangeException \u2192 ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float\u2192Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException \u2014 Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean\u2192Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md.",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 (6/6) \u2014 vs. the local 0.2.0 gateway + real historian over VPN. STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-07-surgical-pure-adds-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T1",
|
||||
@@ -45,20 +45,22 @@
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "Phase 1: over-the-wire SubscriptionSurvivalTests \u2014 subscribe, pure-add, item survives + new node browsable",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4b"
|
||||
],
|
||||
"note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client)."
|
||||
"note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client).",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 central-1 boot logged PureAdd (added=91, rebuild=False)."
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Phase 1: MANDATORY live-/run gate on docker-dev (rebuild BOTH centrals; Client.CLI subscribe survives +1-tag deploy; kind=PureAdd rebuild=False) \u2014 Phase 1 shippable boundary",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5"
|
||||
],
|
||||
"note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good)."
|
||||
"note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good).",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 deleting a UNS virtual tag logged PureRemove (removed=1, rebuild=False) and the node vanished from the address space."
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
@@ -104,11 +106,12 @@
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "Phase 2: remove-side subscription-survival integration test + live-/run remove gate (kind=PureRemove rebuild=False) \u2014 Phase 2 shippable boundary",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
],
|
||||
"note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk."
|
||||
"note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk.",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 surgical add AND remove both execute in prod with no full rebuild; the remove path's 3 new sink members are wired, not inert."
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
@@ -121,11 +124,12 @@
|
||||
{
|
||||
"id": "T14",
|
||||
"subject": "Phase 3: mixed-deploy integration test + live-/run mixed gate + STATUS.md/P1 close-out \u2014 plan complete",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T13"
|
||||
],
|
||||
"note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk."
|
||||
"note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk.",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T5/T6/T12. STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,94 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-10-resilience-observability-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "completed", "blockedBy": ["T1", "T4"] },
|
||||
{ "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "completed", "blockedBy": ["T5"] },
|
||||
{ "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "completed", "blockedBy": ["T4"] },
|
||||
{ "id": "T8", "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", "status": "completed", "blockedBy": ["T7"] },
|
||||
{ "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "completed", "blockedBy": ["T7"] },
|
||||
{ "id": "T10", "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", "status": "deferred-live", "blockedBy": ["T6", "T8", "T9"] },
|
||||
{ "id": "T11", "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", "status": "completed", "blockedBy": ["T10"] }
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1",
|
||||
"T4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit \u2014 verified by T10)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim",
|
||||
"status": "deferred-live",
|
||||
"blockedBy": [
|
||||
"T6",
|
||||
"T8",
|
||||
"T9"
|
||||
],
|
||||
"openNote": "PARTIAL \u2014 the resilience PIPELINE is live-verified (Polly CircuitBreaker -> Retry -> Timeout -> CapabilityInvoker in central-1 stack traces), but breaker-OPEN state was never forced: R2-09's ConnectionBackoff throttles retries so failures don't accumulate fast enough to open the Polly breaker. The reliable trigger is a rapid idempotent write to a dead endpoint. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T10"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,30 +1,213 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] },
|
||||
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "completed", "blockedBy": ["T5"] },
|
||||
{ "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "completed", "blockedBy": ["T2", "T3"] },
|
||||
{ "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] },
|
||||
{ "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] },
|
||||
{ "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] },
|
||||
{ "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
|
||||
{ "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] },
|
||||
{ "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] },
|
||||
{ "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "completed", "blockedBy": ["T17", "T21"] },
|
||||
{ "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "deferred-live", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] }
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "Golden TagConfig corpus + compose\u2192encode\u2192decode parity characterization test (Runtime.Tests, green on unmodified tree)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1",
|
||||
"T3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost \u2192 DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"subject": "DraftValidator swap: Configuration\u2192Commons ProjectReference; ExtractTagConfigFullName \u2192 TagConfigIntent.ExplicitFullName (null-on-absent preserved)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T2",
|
||||
"T3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T2",
|
||||
"T3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T3",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
"subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T14",
|
||||
"subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T15",
|
||||
"subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T16",
|
||||
"subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T17",
|
||||
"subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T18",
|
||||
"subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T17"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T19",
|
||||
"subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T20",
|
||||
"subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T12",
|
||||
"T13",
|
||||
"T14",
|
||||
"T15",
|
||||
"T16",
|
||||
"T17"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T21",
|
||||
"subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T22",
|
||||
"subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)",
|
||||
"status": "completed",
|
||||
"blockedBy": [],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 the typed Modbus tag editor shows the Writable toggle and saving persists `writable: yes`."
|
||||
},
|
||||
{
|
||||
"id": "T23",
|
||||
"subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T17",
|
||||
"T21"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T24",
|
||||
"subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5",
|
||||
"T6",
|
||||
"T7",
|
||||
"T8",
|
||||
"T9",
|
||||
"T10",
|
||||
"T12",
|
||||
"T13",
|
||||
"T14",
|
||||
"T15",
|
||||
"T16",
|
||||
"T17",
|
||||
"T18",
|
||||
"T19",
|
||||
"T21",
|
||||
"T22",
|
||||
"T23"
|
||||
],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T22. STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+667
@@ -0,0 +1,667 @@
|
||||
# Deferment register — new drivers, open issues, deferred work
|
||||
|
||||
> **Scope.** A source-verified inventory of what is unfinished in this repo: the status of the
|
||||
> recently-added drivers, every open tracker issue, in-code deferrals, open live gates, and the
|
||||
> documentation that misstates any of it.
|
||||
>
|
||||
> **Method.** Five parallel agents swept the tree independently; every claim below was checked
|
||||
> against **source** (`src/`, `tests/`, `git log`), not against documentation. Where a doc and the
|
||||
> code disagreed, the code won and the doc is listed in §7. Findings that could not be verified
|
||||
> without hardware or a live rig are marked **CANNOT VERIFY** rather than asserted.
|
||||
>
|
||||
> **Baseline.** `master` @ `90bdaa44`, 2026-07-27. All local branches are merged into master; the
|
||||
> only unmerged remotes are three abandoned branches (`origin/auto/driver-gaps` 184 commits behind
|
||||
> since 2026-04-26, `origin/auto/driver-gaps-stash`, `origin/refactor/galaxy-mxgateway-client-rename`).
|
||||
|
||||
---
|
||||
|
||||
## 0. The short version
|
||||
|
||||
The driver runtime is in good shape — **no new driver contains a stub, a `NotImplementedException`,
|
||||
or an unregistered factory.** What is unfinished clusters into four groups, in descending order of
|
||||
how much they should worry you:
|
||||
|
||||
| # | Theme | Why it matters |
|
||||
|---|---|---|
|
||||
| **1** | **Three subsystems are fully authored, persisted, and shipped — and never executed.** Node ACLs, `IRediscoverable`/`IHostConnectivityProbe`, and `DriverTypeRegistry`. | An operator can author a rule, save it, deploy it green, and have it do **nothing**. §3 |
|
||||
| **2** | **AdminUI authoring-dispatch gaps.** Calculation is offered in the driver picker but has no config form; three drivers have no device form; CSV + browse-commit dispatch maps miss the new drivers. | The "registered but unauthorable" class that has now bitten twice. §1.2 |
|
||||
| **3** | **17 open issues, all confirmed still open**, several broader than filed. | §2 |
|
||||
| **4** | **Bookkeeping drift.** 8 `.tasks.json` files show ~140 "pending" tasks for work that shipped; 5 CLAUDE.md status claims are stale. | Someone follows the tracker and rebuilds a shipped driver. §6, §7 |
|
||||
|
||||
Nothing here is a data-loss or outage defect. The sharpest edge is §3 — silent non-enforcement.
|
||||
|
||||
---
|
||||
|
||||
## 1. New drivers — source-verified status
|
||||
|
||||
Cross-cutting facts, verified once: all 12 driver types register in one place
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`); all 12 register a
|
||||
probe (`:119-130`) via `AddOtOpcUaDriverProbes()`, called from **both** the driver path (`:93`) and
|
||||
the admin path — **no probe is admin-node-missing**. `DriverTypeNames`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`) is guarded bidirectionally by
|
||||
reflection in `tests/Core/…Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs`.
|
||||
|
||||
### 1.1 Status table
|
||||
|
||||
| Driver | Verdict | Merged | Capabilities | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | No `IWritable` **by design** (`:50`). No device form. |
|
||||
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) | Cleanest of the five: picker + driver form + device form + tag editor all present. 8 open follow-up issues. |
|
||||
| **Sql** | Complete-with-gaps | `4ad54037`, follow-ups `28c28667` | `IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe` (`SqlDriver.cs:28-29`) | No device form; no browse-commit branch; stale comments. |
|
||||
| **Modbus RTU-over-TCP** | **Complete** | `0f38f486` (#495) | transport mode inside Modbus (`ModbusTransportMode.cs`, `ModbusRtuOverTcpTransport.cs`) | Parses transport **by name** (`ModbusDriverFactoryExtensions.cs:77-78`) — dodges the systemic numeric-enum authoring bug. |
|
||||
| **Calculation** | **Partial (authoring gap)** | pre-existing | `IDriver, IDependencyConsumer, ISubscribable, IReadable` (`CalculationDriver.cs:38`) | **Registered + offered in the picker, but has no config form.** See §1.2. |
|
||||
|
||||
`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
|
||||
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).
|
||||
|
||||
**Zero `NotImplementedException`** across all five drivers. Every `NotSupportedException` is either a
|
||||
`catch` filter or a deliberate domain error.
|
||||
|
||||
### 1.2 Gaps found — AdminUI authoring & dispatch layer
|
||||
|
||||
Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
|
||||
|
||||
| # | Gap | Evidence | Impact |
|
||||
|---|---|---|---|
|
||||
| G-1 | **`DriverConfigModal.razor` has no `Calculation` case** — falls to the `default` "No typed config form" warning; no `CalculationDriverForm.razor` exists | `DriverConfigModal.razor:34-72`, default arm `:69-71` | `CalculationDriverOptions.RunTimeout` (`CalculationDriverOptions.cs:19`) is **unauthorable via the UI**. Create/rename still work, so degraded — not a hard block. **This is the same class as the Sql picker defect, one modal further downstream.** |
|
||||
| G-2 | **`DeviceModal.razor` is missing Sql, MTConnect, Calculation** | `DeviceModal.razor:42-74`, default arm `:71-73` | Mitigated: Sql/MTConnect hold connection at the **driver** level, and Test Connect still works via `BuildMergedProbeConfig` (`:180-184`). |
|
||||
| G-3 | **`DriverConfigModal.razor:77` tells Sql/MTConnect operators the wrong thing** — the single-connection branch is hardcoded to `Galaxy or Mqtt`, so Sql/MTConnect users are told the endpoint lives on the *device* when they just authored it on the driver | `DriverConfigModal.razor:77`, `:86-88` | Actively misleading, compounds G-2. |
|
||||
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`** — `RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames` ↔ **picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. |
|
||||
| G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. |
|
||||
| G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. |
|
||||
| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. |
|
||||
|
||||
### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader)
|
||||
|
||||
- `DriverTypeNames.cs:22-25` — Calculation described as "not-yet-registered"; it registers at `DriverFactoryBootstrap.cs:149`.
|
||||
- `TagConfigEditorMap.cs:25-28` + `TagConfigValidator.cs:27-28` — "`DriverTypeNames.Sql` deliberately absent until Task 11"; the constant exists (`DriverTypeNames.cs:57`) and the values are identical, so behaviour is correct.
|
||||
- `RawDriverTypeDialog.razor:2-4, 59-60` — "its factory lands in Wave C".
|
||||
- `CsvColumnMap.cs:322-324` — hardcodes `CalculationDriverType = "Calculation"` "Not yet a `DriverTypeNames` constant"; it has been one since `DriverTypeNames.cs:54`.
|
||||
- ~~`SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh
|
||||
instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`.~~ ✅ **Fixed** —
|
||||
the comment now says the premise was false when written and is true *now* because a config change
|
||||
respawns. See §9.
|
||||
|
||||
---
|
||||
|
||||
## 2. Open tracker issues — all 17 verified against source
|
||||
|
||||
**No issue was found stale.** 11 CONFIRMED, 4 PARTIALLY (core claim holds, a sub-claim is wrong or
|
||||
incomplete), 2 CANNOT VERIFY (correctly filed as hardware/fixture-gated).
|
||||
|
||||
| # | Title (abbrev.) | Verdict | Key evidence | Kind |
|
||||
|---|---|---|---|---|
|
||||
| **518** | `IRediscoverable` + `IHostConnectivityProbe` have no consumer; CLAUDE.md wrong | **CONFIRMED — worse than filed** | Only `+=` in `src/` are Galaxy self-wiring (`GalaxyDriver.cs:586`, `:221`). `GetHostStatuses()` has **zero call sites** outside the interface + implementations; `DriverHostStatuses` DbSet is referenced only by its declaration and a test. | Defect + doc error |
|
||||
| **507** | Discovered-node injection inert in v3 | **CONFIRMED** | `DriverHostActor.cs:986-1002` hard-`return;` + `#pragma warning disable CS0162` (restored `:1077`) | Deferred (deliberate guard) |
|
||||
| **519** | No Bad/Uncertain **sub-code** reaches a client | **CONFIRMED** | Collapse at `DriverInstanceActor.cs:1033-1041` (`statusCode >> 30`), re-expand at `OtOpcUaNodeManager.cs:3318-3322`. Enum is 3-state at `IOpcUaAddressSpaceSink.cs:165`. **Wider than filed** — `StatusFromQuality` also used at `:429/:506/:576/:761`, so alarm-condition Quality collapses too. | Design consequence |
|
||||
| **517** | Health published AFTER teardown | **CONFIRMED, fleet-wide** | `ModbusDriver.cs:244-250`, `MTConnectDriver.cs:579-590`, `S7Driver.cs:292-326` — all `Teardown…` then `WriteHealth(Unknown)`; `GetHealth()` is a plain field read | Defect (race) |
|
||||
| **516** | Driver config edits silently discarded | **CONFIRMED — survey now complete** | `DriverInstanceActor.cs:316` (only `_driver` assignment) + `DriverHostActor.cs:2565` (`Tell` existing child, no respawn) + `:653/:660` (green seal). **Affected: Modbus, FOCAS, OpcUaClient, + AbLegacy (issue missed it), + Sql (deliberate/documented).** AbCip/TwinCAT re-parse ✅; Galaxy fails **loud** (throws, `GalaxyDriver.cs:600-641`) | Defect |
|
||||
| **515** | Sparkplug `DataSet`/`Template`/`PropertySet` | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
|
||||
| **514** | Rebirth unarmable on a plant that hasn't birthed | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
|
||||
| **513** | Meaningless `payloadFormat` in Sparkplug blob | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
|
||||
| **512** | `.Browser` → `.Driver` layering | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
|
||||
| **511** | `ActAsPrimaryHost` does nothing | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
|
||||
| **510** | `_canRebirth` captured at browse-open | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
|
||||
| **509** | Metric name with `/` unbrowse-committable | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
|
||||
| **508** | MQTT write-through | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
|
||||
| **507**→ see above | | | | |
|
||||
| **491** | Continuous-historization live gate | **CANNOT VERIFY** (needs VPN'd gateway) | Code **is** complete: `AddressSpaceApplier.cs:238`→`:540`→`ActorHistorizedTagSubscriptionSink.cs:27-38`→`ContinuousHistorizationRecorder.cs:84`, wired `ServiceCollectionExtensions.cs:435` | Deferred test |
|
||||
| **489** | Hot-rebind renamed raw tag | **CONFIRMED** | No implementation exists | Deferred feature |
|
||||
| **481** | Comms-loss → scripted-alarm quality (Layer 4) | **CONFIRMED** | `DriverHostActor.cs:1356-1379` iterates `_alarmNodeIdByDriverRef` only. **Issue is wrong that the per-driver ref set is missing** — `_nodeIdByDriverRef` exists at `:193`; only the fan-out into the mux is absent, so the work is *smaller* than filed | Deferred feature |
|
||||
| **468** | Wave-0 browser tree render | **CANNOT VERIFY** (fixture) | Code path present (`AbCipDriver.cs:1092-1095`, `LibplctagTagEnumerator.cs:29-37`); `ab_server` returns `ErrorUnsupported` for CIP Symbol Object enumeration | Deferred verification |
|
||||
|
||||
### 2.1 Cross-cutting observations
|
||||
|
||||
1. **#518 → #507 are the same failure, stacked.** The event has no subscriber *and* its downstream
|
||||
handler hard-returns. **Fixing either alone changes nothing observable** — schedule them together.
|
||||
2. **#516 is a superset of part of #489.** For the five drivers that discard reinit config, the
|
||||
renamed-tag-never-rebinds symptom follows mechanically (their `_tagsByRawPath` still keys on the
|
||||
old RawPath). Re-scope #489 after #516.
|
||||
3. **#519 and #481 share the same `statusCode >> 30` collapse idiom** at three independent sites
|
||||
(`DriverInstanceActor.cs:1033`, `ScriptedAlarmEngine.cs:745`, `:777`). Widening the status path
|
||||
must cover all three.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dead seams — authored, shipped, never executed
|
||||
|
||||
**The most consequential category in this register**, and the one nothing in the code flags. Three
|
||||
subsystems are fully built and reachable by operators, but no production code path executes them.
|
||||
|
||||
### 3.1 Node-level ACLs are authorable, deployed — and unenforced
|
||||
|
||||
- `IPermissionEvaluator`, `TriePermissionEvaluator`, `PermissionTrieCache`, `PermissionTrieBuilder`
|
||||
have **zero references outside `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and their own
|
||||
tests** (independently re-verified for this report: every other grep hit is a `bin/`/`obj/` XML doc artifact).
|
||||
- `OtOpcUaNodeManager` contains **no reference to `Permission` or `Evaluator` at all.**
|
||||
- Meanwhile `ClusterAcls.razor` + `AclEdit.razor` let operators author `NodeAcl` rows, and
|
||||
**`ConfigComposer.cs:51` snapshots them into every deployment artifact.**
|
||||
|
||||
**Impact:** an operator authors a deny rule, deploys it green, and it has no effect. Actual
|
||||
enforcement is coarse LDAP roles plus the realm-qualified `WriteOperate` gate in the node manager —
|
||||
and that is a **write** gate; reads carry no per-node ACL check.
|
||||
**`docs/ReadWriteOperations.md:13,21` states the opposite** (see §7.1) — it is the single
|
||||
highest-risk doc error found.
|
||||
|
||||
### 3.2 `IRediscoverable` + `IHostConnectivityProbe` raise into the void
|
||||
|
||||
Gitea #518/#507. Nine drivers raise; nothing consumes. `DriverHostStatus` table exists
|
||||
(`OtOpcUaConfigDbContext.cs:48`, migration `20260715230637_V3Initial.cs:108`) with a doc-comment
|
||||
claiming a writer — **nothing ever writes a row.**
|
||||
|
||||
**Impact:** an Agent/PLC/Galaxy restart leaves a stale address space behind a Healthy driver, and the
|
||||
per-host connectivity table is permanently empty.
|
||||
|
||||
### 3.3 `DriverTypeRegistry` is vestigial
|
||||
|
||||
Independently verified for this report: `src/Core/…Core.Abstractions/DriverTypeRegistry.cs:20` is
|
||||
referenced **only** by `tests/Core/…/DriverTypeRegistryTests.cs`. Nothing registers metadata at
|
||||
startup; nothing validates `DriverInstance.DriverType` against it. Its `AllowedNamespaceKinds` field
|
||||
was retired with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`).
|
||||
|
||||
**Knock-on:** the driver **stability tier** does not come from here either. The live source is
|
||||
`DriverFactoryRegistry.cs:46` (`DriverTier tier = DriverTier.A`) and **no factory in `src/Drivers/`
|
||||
passes a tier** — so all 12 drivers run Tier A, and the Tier-C-only protections are dormant for
|
||||
every driver: `MemoryRecycle.cs:54` (`when _tier == DriverTier.C`) and
|
||||
`ScheduledRecycleScheduler.cs:43` (refuses unless Tier C). `docs/v2/driver-stability.md` still
|
||||
assigns Galaxy and FOCAS to Tier C.
|
||||
|
||||
### 3.4 Related dead/dormant code
|
||||
|
||||
| Item | Evidence |
|
||||
|---|---|
|
||||
| `IDriverConfigEditor` — interface, zero implementors, zero production consumers | `Core.Abstractions/IDriverConfigEditor.cs:9`; superseded by `TagConfigEditorMap` / `DriverConfigModal` |
|
||||
| `GenericDriverNodeManager` — explicitly **not** a production dispatch path | `Core/OpcUa/GenericDriverNodeManager.cs:71` ("verified 07/#10: zero production references") |
|
||||
| `IDriverSupervisor` — Tier-C recycle machinery has **zero implementations**, yet the parser still validates `RecycleIntervalSeconds` | archreview 01/U-2 |
|
||||
| Resilience **bulkhead** — documented, defaulted, parsed, AdminUI-authorable; `Build` never adds a concurrency limiter | archreview 01/U-6 |
|
||||
| `WriteIdempotentAttribute` — zero readers; write dispatch hardcodes `isIdempotent: true` | archreview 01/S-8 = 03/S12 |
|
||||
| ~60 lines of unreachable code retained behind #507's guard | `DriverHostActor.cs:1002`, `#pragma warning disable CS0162` |
|
||||
| Three empty `Driver.Historian.Wonderware*` directories (0 `.cs`, no `.csproj`, absent from `.slnx`) | verified for this report — only stale `bin/`+`obj/` remain |
|
||||
|
||||
---
|
||||
|
||||
## 4. In-code deferrals by subsystem
|
||||
|
||||
The tree is unusually clean on classic markers: **9 `TODO`s across 1,812 files; zero
|
||||
`FIXME`/`HACK`/`XXX`/`WORKAROUND`; zero `#if false`; zero `[Obsolete]` on project code; zero
|
||||
commented-out DI registrations.** Real deferred work lives in prose doc-comments and skipped tests.
|
||||
|
||||
### 4.1 Highest-consequence
|
||||
|
||||
| Item | Evidence |
|
||||
|---|---|
|
||||
| **Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" |
|
||||
| **Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` |
|
||||
| **Galaxy writes can never confirm a COM commit** — empty status array ⇒ provisional Good, metered `galaxy.writes.unconfirmed` | `GatewayGalaxyDataWriter.cs:44,328,354` — **cross-repo**, blocked on mxaccessgw |
|
||||
| **Subscription liveness undetectable** — `ISubscriptionHandle` is opaque | `DriverInstanceActor.cs:527` (#488) — well-reasoned, deferred "until a real occurrence" |
|
||||
| **`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing | `RawTree.razor:15,426` — **verify**; may be reachable now via `DiscoveryDriverBrowser` |
|
||||
|
||||
### 4.2 Per-driver
|
||||
|
||||
- **S7** — array **writes** unsupported entirely (`S7Driver.cs:1139`); several array *read* types unsupported (`:386,404,413,419,428,442,465,807,959`) — all fail fast at config-validation; legacy C-area counter reinterpretation live-hardware-gated (`:759`); Counter BCD on S7-300/400 undecoded.
|
||||
- **AbCip** — ALMD-only alarm projection, ALMA deferred (`AbCipAlarmProjection.cs:16,324`); no CIP Template Object reader, UDT layouts declaration-driven (`AbCipDriverOptions.cs:172`); whole-UDT writer deferred (`LibplctagTagRuntime.cs:190`); `AckCmd` pulse must be wired client-side (`:30`).
|
||||
- **Sql** — `SqlTagModel.Query` deferred to P3, **enforced end-to-end** across parser, validator, planner and editor (`SqlProvider.cs:22,37`; `SqlEquipmentTagParser.cs:24,103`; `SqlGroupPlanner.cs:44,249`; `SqlTagConfigModel.cs:114`) — exemplary deferral discipline. Postgres/ODBC dialects deferred behind `ISqlDialect`.
|
||||
- **MTConnect** — `TIME_SERIES` vectors deferred to P1.5; `DATA_SET`/`TABLE` coded `BadNotSupported` with a null value rather than approximated (`MTConnectObservationIndex.cs:53,272`) — deliberately chosen over a Good-coded lie. `RawTagEntry.DeviceName` ignored for routing (two `/raw` Devices under one driver share an Agent connection) — "a design change, not a bug fix". No DataType-vs-blob validation at authoring.
|
||||
- **MQTT** — `MaxPayloadBytes` is a constructor knob, not operator-facing (`MqttSubscriptionManager.cs:170`).
|
||||
- **TwinCAT** — Flat-mode struct expansion carries a **LIVE RISK** note, unverified against a real TC3 target (`AdsTwinCATClient.cs:270`); VirtualTree-mode browse unverified; TC2 coverage, multi-hop AMS route and lab rig all hardware-blocked (`docs/v3/twincat-backlog.md`).
|
||||
- **FOCAS** — forces read-only regardless of authored `writable:true`; servo-load semantics inferred from the wire, unconfirmed against a real CNC (`FocasWireClient.cs:944`); `"Backend": "unimplemented"` is a **deliberate fail-fast stub**, not unfinished work.
|
||||
- **Historian.Gateway** — String/DateTime/Reference writes deferred, gated on the analog SQL write path; `UInt16 → Uint4` widening because the `UInt2` write path is deferred upstream (`HistorianTypeMapper.cs:27-51`).
|
||||
|
||||
### 4.3 Fleet-wide OPC UA gaps
|
||||
|
||||
- **Array writes deferred across all drivers**; **multi-dimensional arrays (`ValueRank>1`) unsupported**; **array historization** is an opaque blob with no per-element history (`docs/Uns.md:288-292`, `AddressSpaceApplier.cs:873`, `OtOpcUaNodeManager.cs:1764`).
|
||||
- **`HistoryUpdate`** — permission bit `1<<12` shipped but excluded from every composite bundle; no service surface (`NodePermissions.cs:34`).
|
||||
- **`Utils.Log*` is `[Obsolete]`** in SDK 1.5.378, suppressed at 8 sites in `OtOpcUaNodeManager` pending an `ITelemetryContext` rewire.
|
||||
|
||||
### 4.4 Skipped tests — 36, from 4 reason constants
|
||||
|
||||
| Count | Reason | Assessment |
|
||||
|---|---|---|
|
||||
| 18 | `DiscoveryInjectionDormantV3` | **Real** — blocked on #507 |
|
||||
| 13 | `EquipmentTagsDarkBatch4` | **STALE** — the reason says these unblock "at Batch 4"; Batch 4 shipped as v3.0 and the path was *retired* (`AddressSpaceApplier.cs:415` calls it "the vestigial equipment-tag path"). These will never unskip as written — delete or rewrite the reason. |
|
||||
| 4 + 1 | `EquipmentDeviceBindingRetired`, `DarkBatch4` | Intentional tombstones preserving retirement rationale — fine as-is |
|
||||
|
||||
Plus **4 whole OpcUaClient test suites that are scaffold-only**, awaiting an in-process OPC UA server
|
||||
fixture that was never built (`OpcUaClientSubscribeAndProbeTests`, `…DiscoveryTests`,
|
||||
`…ReadWriteTests`, `…HistoryTests`, all `:10`).
|
||||
|
||||
All env-gated integration suites **skip cleanly** when their vars are absent. The MTConnect gate is
|
||||
notably a **real `/probe` reachability + payload-shape check** (`MTConnectAgentFixture.cs:42`), not
|
||||
mere env-var presence, so it cannot pass vacuously.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open live gates
|
||||
|
||||
| Gate | Status | Evidence |
|
||||
|---|---|---|
|
||||
| **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists |
|
||||
| **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse |
|
||||
| **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` |
|
||||
| **archreview R2-10** — force breaker-OPEN live | **OPEN** — breaker-OPEN state never forced live | `archreview/plans/R2-10-*.tasks.json` |
|
||||
| **Driver-reconfigure-while-faulted** Task 3 | **OPEN** — task subject literally "DEFERRED" | `2026-07-14-driver-reconfigure-while-faulted-plan.md.tasks.json` |
|
||||
| Galaxy `ScanStateProbeParityTests` | **OPEN** — licence-gated (one `$WinPlatform` on this box) | `docs/v2/Galaxy.ParityRig.md:180` |
|
||||
| **v2 GA exit criteria** — FOCAS live-CNC wire smoke (#54), OPC UA CTT/compliance pass, Ignition 8.3 redundancy cutover | **OPEN** — manual/hardware/external-tool | `docs/v2/v2-release-readiness.md:105-120` |
|
||||
| mesh Phase 4 | ✅ **PASSED** `1281aebf` | CLAUDE.md said open — **corrected**, §7.2 |
|
||||
| mesh Phase 5 | ✅ **PASSED** `f134935f` | CLAUDE.md said pending — **corrected**, §7.2 |
|
||||
| auto-down 1-vs-1 crash-the-oldest | ✅ **PASSED** — Phase 7 drill D4, `c50ebcf7` | CLAUDE.md said open — **corrected**, §7.2 |
|
||||
|
||||
**Note on the 14 archreview `deferred-live` tasks across 8 plans:** `archreview/plans/STATUS.md:242-305`
|
||||
records a 2026-07-13 live pass closing several (R2-02, R2-07, R2-11, R2-04, R2-05-partial) and
|
||||
2026-07-15 closures for R2-01/R2-08/R2-06 — **but the `.tasks.json` files were never updated.** Only
|
||||
R2-03 and R2-10 have no closure record anywhere.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan bookkeeping — stale vs. live
|
||||
|
||||
**59 of 78 `docs/plans/*.tasks.json` are 100 % complete.** Of the remaining 19, most are stale
|
||||
bookkeeping, not backlog.
|
||||
|
||||
### 6.1 STALE — work shipped, file never updated (~140 phantom "pending" tasks)
|
||||
|
||||
| File | Recorded | Reality |
|
||||
|---|---|---|
|
||||
| `2026-07-24-mtconnect-driver.md.tasks.json` | 23 pending | Merged at master tip `90bdaa44`, 5-leg live gate PASSED |
|
||||
| `2026-07-24-mqtt-sparkplug-driver.md.tasks.json` | 27 pending | All 27 done, both phases live-gated |
|
||||
| `2026-07-24-sql-poll-driver.md.tasks.json` | 22 pending | Merged `4ad54037` + follow-ups `28c28667` |
|
||||
| `2026-07-24-modbus-rtu-driver.md.tasks.json` | 11 pending | Merged `0f38f486` (#495), live gate PASSED |
|
||||
| `2026-06-26-otopcua-historian-gateway-integration.md.tasks.json` | 21 pending | Shipped as PR #423, live-validated |
|
||||
| `2026-06-15/16-stillpending-phase-{0-1,2,3,4}.md.tasks.json` | 12+9+10+8 pending | Shipped — audit §D cites `1e95856b`, `ada01e1a`, `fc8121cb`, `3e609a2b`, `70e6d3d2`, `c236263e`, `bd8fee61`, `5e27b5f7`, `fcb38014` |
|
||||
| `2026-07-22-per-cluster-mesh-program.md.tasks.json` | Phase 2 in_progress, 3–7 pending | Plan body `:331-342` says **program COMPLETE** |
|
||||
| `2026-06-12-historian-tcp-transport.md.tasks.json` | 12 pending | **OBSOLETE** — targets the retired Wonderware sidecar |
|
||||
|
||||
### 6.2 GENUINELY LIVE
|
||||
|
||||
| File | Open | Note |
|
||||
|---|---|---|
|
||||
| **`2026-05-29-adminui-followups.md.tasks.json`** | **19 pending / 0 completed** | **The largest unexecuted plan in the tree.** Generic `CollectionEditor<TRow>`; per-driver device/tag editors ×7; typed resilience form; `RoleMapper.Merge` + DB-backed LDAP→role mapping + `RoleGrants.razor` + FleetAdmin authz. **Several items look superseded by v3's `TagConfigEditorMap` — verify overlap before executing.** |
|
||||
| `2026-06-19-followups-batch.md.tasks.json` | 4 pending + 1 partial | B4 F10b surgical DataType/IsArray writes and B5 alarm-severity `SetSeverity` are **OPEN-by-choice, not built** |
|
||||
| `2026-06-26-otopcua-fixedtree-followups.md.tasks.json` | 1 pending | Task 11 build + regression |
|
||||
| `2026-05-26-akka-hosting-alignment-plan.md.tasks.json` | 5 partial | F8/F9/F10/F13/F14 — oldest open items; **likely superseded by v3, verify** |
|
||||
| `2026-05-28-adminui-driver-pages-plan.md.tasks.json` | 1 partial | 10.4 manual smoke checklist |
|
||||
|
||||
`docs/plans/2026-07-23-mesh-phase4-…tasks.json` Task 9 was the only `deferred`-flagged mesh task —
|
||||
**it has since landed** (`3a590a0c`), so that file is now stale too.
|
||||
|
||||
### 6.3 Review-directory state
|
||||
|
||||
- **`code-reviews/`** — 51 modules, **zero Open findings**. One textual "Left Open pending a decision"
|
||||
survives in a finding body (`Configuration/findings.md:254`, LDAP collation) whose status field
|
||||
reads Deferred — the only index inconsistency.
|
||||
- **`code-reviews/` coverage gap** — **10 shipped source projects have never been reviewed**:
|
||||
`Driver.Calculation`, `Driver.Historian.Gateway`, `Driver.Mqtt` (+`.Browser`, `.Contracts`),
|
||||
`Driver.MTConnect` (+`.Contracts`), `Driver.Sql` (+`.Browser`, `.Contracts`). Meanwhile three
|
||||
modules review the **retired** Wonderware backend.
|
||||
- **`archreview/`** — `00-OVERALL.md:60-79` carries an explicit "Carried open … no remediation branch
|
||||
targeted them" block. The largest coherent batch is the **failure-visibility trio**: 01/S-1
|
||||
(`AddressSpaceApplier` swallows every sink failure, deploy reports Applied even when broken), 03/S4
|
||||
(primary gate default-allow on unknown role), 06/S-1 (Galaxy optimistic write success).
|
||||
- **`stillpending.md` does not exist** — the backlog is `docs/plans/2026-06-18-stillpending-backlog-audit.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Documentation corrections
|
||||
|
||||
### 7.1 Applied in this pass
|
||||
|
||||
| File | Was | Now |
|
||||
|---|---|---|
|
||||
| `CLAUDE.md` §Change Detection (`:156`) | "The server's `DriverHost` consumes the signal and rebuilds the address space" | ⚠️ nothing consumes it; names `DriverHostActor.cs:986`, #518/#507, and the four affected drivers |
|
||||
| `CLAUDE.md` §Redundancy (`:272`) | auto-down "live gate is still open … docker-dev is a single six-node mesh" | gate **CLOSED** by Phase 7 drill D4 (`c50ebcf7`); the six-node-mesh claim also contradicted CLAUDE.md's own Phase 6 paragraph |
|
||||
| `CLAUDE.md` §Telemetry (`:383`) | Phase 5 "code-complete on `feat/mesh-phase5`, live gate pending" | merged `35552a96`; live gate PASSED `f134935f` |
|
||||
| `CLAUDE.md` §Phase 4 (`:493`, `:502-503`) | `ScriptedAlarmState` "dropping it is a deferred follow-up, tracked as Task 9"; "Task 9 and the live gate (Task 10) are still open" | table **dropped** (`3a590a0c`, migration `20260723183050_DropScriptedAlarmStateTable`); live gate PASSED `1281aebf` |
|
||||
| `docs/plans/2026-07-24-driver-expansion-tracking.md` | Modbus RTU "pending merge"; SQL poll "📝 Plan ready (22 tasks)"; **an "Executing a plan" table instructing you to rebuild both** | both marked ✅ Done with merge SHAs; the two shipped rows struck from the command table; Wave 1/2 headings and §Next actions corrected |
|
||||
| `docs/drivers/README.md` | `DriverTypeRegistry` "registered at startup"; "namespace kinds (Equipment + SystemPlatform)"; no Sql/Calculation rows; "Modbus TCP" only | registry marked vestigial + tier truth; namespace line corrected to Raw+UNS; Sql + Calculation rows added; Modbus row covers RTU-over-TCP |
|
||||
| `docs/drivers/TwinCAT.md` (`:95`, `:99-103`, `:151`) | "so the address space is rebuilt" / "so Core rebuilds the address space" | raises-but-unconsumed caveat, mirroring the wording `Mqtt.md`/`MTConnect.md` already use correctly |
|
||||
| `docs/drivers/Galaxy.md` (`:73`) | `IRediscoverable` row with no caveat | same caveat added |
|
||||
| `docs/v2/Runtime.md` (`:106`) | "named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink`" | gRPC to HistorianGateway + `LocalDbStoreAndForwardSink` |
|
||||
| **`docs/ReadWriteOperations.md`** (`:13`, `:21` + banner) | "ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch"; "**A denied read never hits the driver**" | Both struck through and corrected; banner states plainly that **no per-node ACL gate exists on any operation** and that `GenericDriverNodeManager` is test scaffolding. **This was the highest-risk doc error found.** |
|
||||
| **`docs/IncrementalSync.md`** (banner) | whole "Rebuild flow" + generation-publish path presented as current | Banner: `GenericDriverNodeManager` is non-production, rediscovery has no consumer, `sp_PublishGeneration`/`sp_ComputeGenerationDiff` `RAISERROR`, `DraftRevisionToken` gone; names the one live path (deploy artifact) |
|
||||
| **`docs/AddressSpace.md`** (banner) | "single custom namespace"; `Phase7Applier`/`Phase7Composer`/`GalaxyTagPlan`; `SystemPlatform` | Banner: two namespaces w/ `V3NodeIds`, the `AddressSpace*` rename (`40e8a23e`), `AddressSpaceComposition` as the result type, `SystemPlatform` retired |
|
||||
| `docs/v2/phase-7-status.md`, `v2-release-readiness.md`, `redundancy-interop-playbook.md`, `AdminUI-rebuild-plan.md` (banners) | present-tense claims about types that no longer exist | Dated "⚠️ Historical" banners naming the specific dead types. `v2-release-readiness.md`'s banner also flags that its `:41` "ACL enforcement Closed" row is false, while noting its unchecked GA exit criteria **are** still live. (`docs/StatusDashboard.md` already self-declares Superseded — left alone.) |
|
||||
| `docs/OpcUaServer.md` (`:14`, `:51`), `docs/README.md` (`:65`), `docs/drivers/OpcUaClient-Test-Fixture.md` (`:127`) | `WriteAlarmState`; `LdapUserAuthenticator` ×2; `RedundancyCoordinator` | `WriteAlarmCondition`; `LdapOpcUaUserAuthenticator`; `RedundancyStateActor` + `IRedundancyRoleView` |
|
||||
| `docs/Historian.md` | no mention of #491 or the provisioning gap | ⚠️ block added: continuous value capture is **code-complete but unproven in production**, with the wiring chain; plus the `EnsureTags`-skips-existing-tags gap |
|
||||
|
||||
### 7.2 Recorded, NOT applied — needs a decision or a rewrite, not a line edit
|
||||
|
||||
The three highest-priority items in this list were **bannered rather than rewritten** in this pass
|
||||
(cheap, stops the misleading, preserves the record) — the underlying rewrite is still owed. They now
|
||||
appear in §7.1 as well.
|
||||
|
||||
| Priority | File | Problem |
|
||||
|---|---|---|
|
||||
| **1 (security-relevant)** | `docs/ReadWriteOperations.md:13,21` | **Bannered + struck through.** Claimed ACL enforcement via `WriteAuthzPolicy` + `AuthorizationGate` + `NodeScopeResolver` and that "a denied read never hits the driver". All four names have zero hits in `src/`; §3.1 shows there is no read-path ACL gate at all. **Still owed:** a proper §-rewrite against the real node-manager write gate. Same false claim at `docs/v2/v2-release-readiness.md:41` (bannered). |
|
||||
| **2** | `docs/IncrementalSync.md` | **Bannered.** Entire "Rebuild flow" (`:37-47`) built on non-production `GenericDriverNodeManager`; `:26-34` cites `sp_PublishGeneration`/`sp_ComputeGenerationDiff`, both retired stubs that `RAISERROR` (`20260715230637_V3Initial.StoredProcedures.cs:194`); `:31` cites `DraftRevisionToken` (0 hits); `:49-51` describes a `ScopeHint` no consumer reads. **Still owed:** rewrite around the deploy-artifact path, or archive to `docs/v1/`. |
|
||||
| **2** | `docs/AddressSpace.md` | **Bannered.** v2-era throughout: `:7,42` claim a single namespace (v3 has two); `:7,48,70` cite `Phase7Applier` + `GalaxyTagPlan` + a nonexistent file path; `:62-64` repeats the rediscovery claim. **Still owed:** rewrite §"Root folder" + §"NodeId scheme" against `V3NodeIds` / `AddressSpaceRealm`. |
|
||||
| 3 | 6 docs, ~20 citations | **`Phase7*` → `AddressSpace*`** rename (`40e8a23e`). Mostly mechanical, **except three that are not renames** and need the real member identified first: `Phase7CompositionResult` → **`AddressSpaceComposition`** (`AddressSpaceComposer.cs:13`, verified for this report); `Phase7Composer.ExtractTagFullName` → nearest live seam is `ScriptTagCatalog.ExtractFullNameFromTagConfig`; **`Phase7EngineComposer.RouteToHistorianAsync` is simply gone** — the real seam is `HistorianAdapterActor` → `IAlarmHistorianSink`. |
|
||||
| 3 | `docs/v2/driver-specs.md` | Covers 8 of 12 drivers (missing MQTT, MTConnect, Sql, Calculation, and the Modbus `Transport` selector) while `docs/drivers/README.md` calls it authoritative "for **every** driver". Either add four sections or downgrade the claim. |
|
||||
| 3 | `docs/v2/driver-stability.md:47-54` | Puts Galaxy and FOCAS in Tier C; `docs/drivers/README.md` says Tier A for both. **Moot at runtime** — see §3.3 — but the two docs contradict each other. |
|
||||
| 3 | `docs/drivers/Sql.md`, `docs/drivers/Calculation.md` | **Do not exist.** Sql is documented only in design/plan/research files; Calculation only at `docs/Raw.md:66-69`. |
|
||||
| — | `docs/operations/2026-07-16-secrets-clustered-master-key.md:38` | `NoOpSecretReplicator` exists **only** in a test. **Investigate before editing** — the sentence makes a security-relevant claim ("no built-in cross-node replication today"), so it needs the real behaviour identified, not a rename. Left untouched deliberately. |
|
||||
| — | `docs/v2/driver-specs.md` §2 | `docs/drivers/Modbus.md:10-11` points readers here for the Modbus config shape, and this file omits the `Transport` selector. Folded into the driver-specs item above. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommended sequencing
|
||||
|
||||
1. ~~**§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or
|
||||
remove the authoring UI and say plainly that ACLs are not enforced. Either way fix
|
||||
`docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~
|
||||
✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9.
|
||||
(`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.)
|
||||
2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right
|
||||
that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
|
||||
so it was deleted rather than fixed. See §9.
|
||||
3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
|
||||
✅ **Done.** See §9. #489 is still open and should now be re-scoped.
|
||||
4. ~~**G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
|
||||
have caught G-1 and G-2 for free, and this class has now recurred twice.~~ ✅ **Done**, together with
|
||||
G-1, G-2, G-3, G-5 and G-6. See §9.
|
||||
5. ~~**Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
|
||||
(§6.2) is visible as the real backlog it is.~~ ✅ **Done.** See §9.
|
||||
6. ~~**§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C
|
||||
machinery; today it is documented, authorable and dormant.~~ ✅ **Documented**; the keep-or-delete
|
||||
code decision is Gitea **#522**. See §9.
|
||||
|
||||
---
|
||||
|
||||
## 9. Execution log
|
||||
|
||||
Remediation of §8, on branch `feat/deferment-remediation`. Status is updated in the **same commit** as
|
||||
the work, so this register never disagrees with the tree.
|
||||
|
||||
| §8 item | Status | Commit |
|
||||
|---|---|---|
|
||||
| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` |
|
||||
| 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` |
|
||||
| 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` |
|
||||
| 4. G-4 dispatch-map parity | ✅ **Done** — G-1…G-6 all closed, live-verified on docker-dev | `abacf4cf` |
|
||||
| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `03658c2e` |
|
||||
| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `03658c2e` |
|
||||
|
||||
### 2026-07-27 — §8.1 ACL non-enforcement made explicit
|
||||
|
||||
Decision: **do not wire the evaluator, do not delete it** — state plainly that it does not run, and
|
||||
track the wire-up as **Gitea #520**.
|
||||
|
||||
Applied:
|
||||
|
||||
- `docs/security.md` — the `NodeScope`/`PermissionTrie`/"Dispatch gate" block now leads with a
|
||||
**What is enforced today** table (the three real checks) before the design material, which is
|
||||
labelled *Designed but not wired*.
|
||||
- `docs/ReadWriteOperations.md` — **rewritten**, not bannered.
|
||||
- `docs/v2/acl-design.md` — ⚠️ NEVER WIRED banner.
|
||||
- `docs/v2/v2-release-readiness.md` §"Security — Phase 6.2 dispatch wiring" — the CLOSED claim marked
|
||||
as not holding.
|
||||
- `ClusterAcls.razor` + `AclEdit.razor` — warning alerts stating rules are stored and deployed but
|
||||
never evaluated.
|
||||
|
||||
**Four things this pass found that §7 and the original audit missed** — all worse than what was
|
||||
already recorded:
|
||||
|
||||
1. **`docs/security.md` was the highest-risk doc, not `ReadWriteOperations.md`.** `:115` claimed an
|
||||
anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite is
|
||||
true: an anonymous session can Browse, Read, Subscribe and HistoryRead the **entire** address
|
||||
space. It is refused only writes and alarm acks — and only because it carries no roles.
|
||||
2. **Three of the five documented data-plane role strings do nothing.** `OpcUaDataPlaneRoles` declares
|
||||
exactly `WriteOperate` and `AlarmAck`. `ReadOnly`, `WriteTune` and `WriteConfigure` are compared
|
||||
against nowhere in `src/`, so mapping a group to `WriteTune` grants nothing and mapping one to
|
||||
`ReadOnly` restricts nothing. The doc called all five "exact, case-insensitive, and **code-true**".
|
||||
3. **`ReadWriteOperations.md` was fiction well beyond the ACL claims.** `OnReadValue` has **zero**
|
||||
occurrences in `src/` — the whole documented read path did not exist. Reads never reach a driver at
|
||||
all: the node manager is push-model and the SDK serves a client Read from the cached pushed value.
|
||||
`WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` and `IRoleBearer` are likewise
|
||||
zero-hit, and `CapabilityInvoker` is not referenced by the `OpcUaServer` project at all.
|
||||
4. **`v2-release-readiness.md` claimed a whole enforcement layer shipped.** Beyond the `:41` row §7.1
|
||||
already flagged, `:46-50` describe `FilterBrowseReferences`, `GateCallMethodRequests`,
|
||||
`MapCallOperation`, `AuthorizationBootstrap` and `Node:Authorization:*` config keys as Closed or
|
||||
Partial. None of them exist. Whatever landed on the v2 branch did not survive into the shipped tree.
|
||||
|
||||
Also recorded in #520 and not previously known: every existing evaluator unit test runs in
|
||||
`PermissionTrieBuilder`'s no-`scopePaths` *deterministic test mode*, so the production hierarchy path
|
||||
is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle
|
||||
silently.
|
||||
|
||||
### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted
|
||||
|
||||
Decision: **signal, not mutation.**
|
||||
|
||||
`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach
|
||||
in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is
|
||||
advisory — the served address space is unchanged, because v3 authors raw tags through `/raw`
|
||||
browse-commit and a runtime graft would materialise nodes nobody approved.
|
||||
|
||||
**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)".
|
||||
That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are
|
||||
*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing.
|
||||
Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
|
||||
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
|
||||
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the
|
||||
connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse
|
||||
picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor.
|
||||
|
||||
**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2
|
||||
characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3
|
||||
cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in
|
||||
`Runtime.Tests` went 31 → 13.
|
||||
|
||||
**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time
|
||||
discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT,
|
||||
AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so
|
||||
the diff is permanently empty for them. It would have been another plausible-looking inert seam —
|
||||
precisely the class this register exists to remove. That also removed the last consumer of the
|
||||
connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect
|
||||
and dropping the result.
|
||||
|
||||
Two traps worth keeping:
|
||||
|
||||
- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the
|
||||
four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal.
|
||||
**Verified by reverting the one line — 3 of the 5 new tests go red.**
|
||||
- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call
|
||||
and the dedup never engages. A dedup test built on it would pass whether or not the fix is present;
|
||||
the new tests use a stub with stable health.
|
||||
|
||||
`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the
|
||||
`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
|
||||
inference would be wrong. Split to Gitea **#521** with both options costed.
|
||||
|
||||
### 2026-07-27 — §8.5 bookkeeping + §8.6 tier truth
|
||||
|
||||
**13 plan files closed.** The 8 the register named, plus `mesh-phase4` (Task 9 landed `3a590a0c`), the
|
||||
4th `stillpending` phase file, and `historian-tcp-transport` closed as **OBSOLETE** (it targets the
|
||||
retired Wonderware sidecar; there is no Wonderware backend in the tree). Each carries a `closureNote`
|
||||
naming the evidence, so the next reader can check rather than trust.
|
||||
|
||||
**11 archreview live gates written back** from `STATUS.md`, which had recorded them passed since
|
||||
2026-07-13/15 without ever updating the `.tasks.json` files: R2-01 #11, R2-02 #15/#18, R2-05 T15,
|
||||
R2-06 T12, R2-07 T5/T6/T12/T14, R2-11 T22/T24.
|
||||
|
||||
**R2-03 and R2-10 were NOT closed** — and reading `STATUS.md` rather than trusting its own headline
|
||||
("Every Round-2 live gate is now GREEN") is what kept that honest. Its per-item detail says R2-03 is
|
||||
"live-blocked on this data-less rig" and R2-10 verified the pipeline but "breaker-OPEN state not
|
||||
forced". Both now carry an `openNote` recording the blocker: R2-03 needs a reachable driver fixture
|
||||
feeding a VT; R2-10 needs a rapid idempotent write to a dead endpoint, because R2-09's
|
||||
`ConnectionBackoff` throttles retries so failures never accumulate fast enough to open the Polly breaker.
|
||||
|
||||
**Net effect (the point of the sweep):** ~140 phantom pending tasks are gone, so the genuinely
|
||||
unexecuted **19-task AdminUI follow-ups plan** is now the largest open item in `docs/plans/` rather than
|
||||
being buried. The remaining open plans are 8, and one of those (`mesh-phase6` Task 6) is a documented
|
||||
`resolved-no-flip` decision rather than work.
|
||||
|
||||
**Tier truth:** `docs/v2/driver-stability.md` now states plainly that its tier table is aspirational —
|
||||
every driver runs Tier A, and `MemoryRecycle` / `ScheduledRecycleScheduler` have never engaged. It also
|
||||
corrects a second stale claim the register did not flag: the **separate-Windows-service hosting** it
|
||||
describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in PR 7.2; FOCAS went
|
||||
in-process when its managed wire client landed). No runtime change — turning recycle on for two live
|
||||
drivers deserves its own live gate, not a docs-pass side effect. Tracked as Gitea **#522**.
|
||||
|
||||
### 2026-07-28 — the two things §8.3 deliberately did NOT build (1 of 2)
|
||||
|
||||
**Sql and FOCAS now re-parse in place too.** The first pass excluded them for a real reason — each derives
|
||||
more than options from config, so adopting new options alone would run a **new tag set against an old
|
||||
connection/backend**, and a half-applied change is worse than a discarded one because it looks like it
|
||||
worked. Rather than accept that, the blocker itself is fixed: each factory now exposes a **`ParseBinding`**
|
||||
that returns *every* config-derived dependency as one value, and the driver adopts them **atomically**.
|
||||
|
||||
| Driver | What one parse now yields | Adopted by |
|
||||
|---|---|---|
|
||||
| Sql | options + `ISqlDialect` + resolved connection string | `ApplyBinding`, which also rebuilds the provider factory, `Endpoint` and the `SqlPollReader` that captures all three |
|
||||
| FOCAS | options + the `IFocasClientFactory` the `Backend` key selects | one assignment pair in `InitializeAsync` |
|
||||
|
||||
Details worth keeping:
|
||||
|
||||
- **Sql adopts BEFORE `BuildTagTable`**, so the tag table and the connection it will be polled over always
|
||||
come from the same revision.
|
||||
- **A test-injected `DbProviderFactory` survives a rebind** (`_explicitFactory`), so a re-derived dialect
|
||||
cannot silently displace what a test passed in.
|
||||
- **Re-resolving the connection string on reinit is a side benefit**: a rotated credential is picked up
|
||||
without a process restart.
|
||||
- A driver constructed **directly** gets no rebinder and keeps its constructor-supplied binding — every
|
||||
existing `"{}"`-passing lifecycle test is unaffected by construction, not by luck.
|
||||
|
||||
**The tests pin ATOMICITY, not merely "a re-parse happened"** — a test that only checked options would have
|
||||
passed against the broken version. Verified by simulating the *half-fix* (adopt options, skip the backend):
|
||||
2 of the 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts
|
||||
that a reinit which **cannot resolve** its new connection leaves the previous binding whole, rather than
|
||||
half-adopting.
|
||||
|
||||
All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer
|
||||
guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
|
||||
|
||||
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
|
||||
|
||||
**The discovery-drift detector is built, and gated.** The original objection stands and is now *encoded*
|
||||
rather than used as a reason not to build: Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
|
||||
`DiscoverAsync`, so a diff for them is a tautology. The missing piece was a discriminator — and the
|
||||
codebase already had one. `ITagDiscovery.SupportsOnlineDiscovery` is documented as "enumerates the tag set
|
||||
from the **live backend** rather than replaying pre-declared/authored tags", and it separates the fleet
|
||||
cleanly: **FOCAS, TwinCAT, MTConnect, AbCip** true; the five config-echo drivers explicitly false.
|
||||
|
||||
The check runs only for a driver that is `SupportsOnlineDiscovery` **and** reports the new
|
||||
`AuthoredDiscoveryRefs` (**nullable**, default null = opt out — an *empty* collection legitimately means
|
||||
"nothing authored, everything discovered is new", and conflating the two would report total drift for a
|
||||
driver that simply never opted in).
|
||||
|
||||
**Where the value is:** AbCip and FOCAS browse a live backend but implement **no `IRediscoverable`**, so
|
||||
before this they had *no* change signal at all — a periodic compare is the only way to notice a PLC
|
||||
re-download. TwinCAT and MTConnect already have native signals (symbol-version, agent instanceId); drift
|
||||
detection is complementary there.
|
||||
|
||||
**A finding that changed the design.** FOCAS, TwinCAT and AbCip all **re-emit their authored tags into the
|
||||
same `DiscoverAsync` stream** as the device-derived ones, so the browse picker can show an operator their
|
||||
existing tags. That means discovered ⊇ authored *always*, and a **vanished** tag can never appear missing
|
||||
— one whole direction is structurally undetectable for three of the four. Shipping a `Vanished` list that
|
||||
is permanently empty for them would have been exactly the half-inert seam this register exists to remove.
|
||||
So the driver declares `DiscoveryStreamIncludesAuthoredTags`, the detector does not compute the
|
||||
undetectable half, and the operator message **says** "missing-tag detection unavailable for this driver"
|
||||
rather than letting the absence of a missing-tag clause read as reassurance. **MTConnect is the only
|
||||
driver where both directions work** — its stream is purely probe-model-derived.
|
||||
|
||||
Behavioural details: a **failed browse is not drift** (an unreachable device would otherwise report every
|
||||
authored tag as vanished — the health surface already covers unreachability); a **truncated** capture is
|
||||
skipped for the same reason; an **unchanged** drift is reported once rather than re-stamping its timestamp
|
||||
every interval; drift that **resolves** clears the prompt so the next one is not deduped against a stale
|
||||
signature. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an
|
||||
engineering event, not a runtime one.
|
||||
|
||||
The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather than actor-inline logic.
|
||||
14 new tests. The gate was verified load-bearing by deleting the `SupportsOnlineDiscovery` half — the
|
||||
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no
|
||||
prompt raised", which would have passed for the wrong reason if the timer simply never fired.
|
||||
|
||||
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
|
||||
|
||||
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
|
||||
`BuildRenderTree`'s IL, so nothing can enumerate its cases — that is the whole reason G-1 and G-2
|
||||
survived review while the sibling picker guard stayed green (the picker test works only because
|
||||
`RawDriverTypeDialog` keeps its data in a *field* the markup enumerates). Both modals now render from
|
||||
`DriverConfigFormMap` / `DeviceFormMap` via `<DynamicComponent>`, and being `public` those maps need
|
||||
none of the picker test's `BindingFlags.NonPublic` fragility.
|
||||
|
||||
| Gap | Resolution |
|
||||
|---|---|
|
||||
| G-1 | `CalculationDriverForm.razor` added — `RunTimeout` was unauthorable through the UI. |
|
||||
| G-2 | Sql / MTConnect / Calculation are declared **single-connection** rather than given hollow device forms — each holds one connection at the driver level, so a per-device endpoint editor would be meaningless. |
|
||||
| G-3 | `DriverConfigModal`'s hardcoded `Galaxy or Mqtt` replaced by `DeviceFormMap.IsSingleConnection`. |
|
||||
| G-4 | `DriverFormMapParityTests` (5 cases, both directions) + `DriverDispatchMapParityTests` (3). |
|
||||
| G-5 | `CsvColumnMap` entries for Sql, Mqtt, MTConnect. |
|
||||
| G-6 | `RawBrowseCommitMapper` Sql branch — **and the browser end too**, which the register missed. |
|
||||
|
||||
**G-6 was bigger than filed.** The register said the mapper lacked a Sql branch. It also turned out that
|
||||
`SqlBrowseSession` emitted **no `AddressFields` at all**, so a browsed leaf carried only a column name —
|
||||
and a column name alone cannot address a Sql tag, which needs its table. The browser now travels
|
||||
schema/table/column and the mapper builds a `WideRow` `SqlTagConfigModel` from them. A branch alone
|
||||
would have produced a half-built blob.
|
||||
|
||||
**Two extra findings while writing the guards:**
|
||||
|
||||
- The G-6 test found **Modbus and Calculation** also fall through to the generic `{"address": …}` key.
|
||||
Both are correct — neither is browsable — so the test asserts the fall-through set **equals** a
|
||||
documented non-browsable list, in both directions. A one-way check would let a newly-browsable driver
|
||||
be quietly added to the exclusion list instead of getting a branch.
|
||||
- `TagConfigDriverTypeNameGuardTests` was **hollow**: it enumerated a hand-written `TheoryData` that had
|
||||
already drifted (omitting Galaxy, Sql and Mqtt), so it guarded against renames but not gaps — a new
|
||||
`DriverTypeNames` constant would simply never appear. Now enumerated from the map, paired with a
|
||||
coverage test in the other direction.
|
||||
|
||||
**Live-verified on docker-dev** (`localhost:9200`, central pair rebuilt), because this repo has no bUnit
|
||||
and no unit test can cover Blazor parameter binding: opened Calculation's config — the form renders where
|
||||
it previously showed "No typed config form" — typed `3500`, saved, reopened, and the value **persisted**,
|
||||
proving the `DynamicComponent` two-way binding round-trips. Sql's form renders fully (regression check on
|
||||
the conversion) and now reads "This driver holds a single connection, authored above" (G-3). The rebuilt
|
||||
image also showed `RediscoveryNeededUtc`/`RediscoveryReason` on the wire, confirming §8.2 end-to-end.
|
||||
|
||||
### 2026-07-27 — §8.3 #516 driver config edits silently discarded
|
||||
|
||||
Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the
|
||||
"per-driver" half in two, which the register did not anticipate:
|
||||
|
||||
- **Re-parse in place** (`Modbus`, `AbLegacy`, `OpcUaClient`) — `ParseOptions` extracted from each
|
||||
factory, called from `InitializeAsync` behind a `HasConfigBody` guard so `"{}"` still keeps the
|
||||
constructor options.
|
||||
- **Respawn-only, deliberately NOT re-parsed** (`Sql`, `FOCAS`) — each builds **more than options**
|
||||
from config: Sql's `ISqlDialect` + resolved connection string, FOCAS's client-factory backend, both
|
||||
injected at construction. Adopting new options alone would run a **new tag set against an old
|
||||
connection**. Half a re-parse is worse than none. Their doc-comments now say so.
|
||||
|
||||
**The seam respawn is the load-bearing half.** `DriverSpawnPlanner` now routes a changed
|
||||
`DriverConfig` to `ToStop` + `ToSpawn`, making the factory the single parse authority.
|
||||
`ToApplyDelta` is consequently always empty from the reconcile path. This **reverses the deliberate
|
||||
decision documented at `DriverSpawnPlan.cs:49-50`** ("a pure DriverConfig change stays an in-place
|
||||
delta — no reconnect"). That reasoning was right about resilience and wrong about the driver; the
|
||||
accepted price is a reconnect on every config edit.
|
||||
|
||||
Two seals removed from `ApplyChildDelta`: it overwrote the cached `Spec` **synchronously, before the
|
||||
child had dequeued the message**, so the host immediately believed the new config was live and the
|
||||
next reconcile computed no delta — sealing the drift permanently; and it `Tell`d with no
|
||||
`Receive<ApplyResult>` registered, so a failed reinit (including Galaxy's deliberate
|
||||
`NotSupportedException`) dead-lettered.
|
||||
|
||||
**A new visibility gap this change exposed, not created:** a factory throw is a *config* error
|
||||
(`TryCreate` is pure parsing; device I/O happens later), and `SpawnChild` catches it and silently
|
||||
substitutes a stub. Previously only a brand-new driver could hit it; now an ordinary config edit can.
|
||||
Raised from `Warning` to `Error` with an actionable message. It still does **not** fail the
|
||||
deployment — making it do so would let one malformed driver block a fleet deploy, so that is a
|
||||
deliberate follow-up rather than a drive-by change.
|
||||
|
||||
**Tests.** Every pre-existing reinit test in the five suites passes `"{}"` — precisely the input a
|
||||
guarded re-parser treats as "keep the constructor options", so they were blind to this defect *by
|
||||
construction*. The new tests pass **changed** JSON. The Modbus one was verified falsifiable by
|
||||
deleting the re-parse line (goes red). Two existing tests asserted the old behaviour and were
|
||||
rewritten: `DriverSpawnPlannerTests` (two cases), and
|
||||
`DriverHostActorUnreadableArtifactTests.Dropping_a_drivers_last_tag_does_clear_its_subscription` —
|
||||
a **positive control** for a sibling absence assertion, whose observable moved from `UnsubscribeAsync`
|
||||
to `ShutdownAsync` because the teardown now happens by stopping the child rather than emptying its
|
||||
desired set. Same event, different route; the control still calibrates the settle window.
|
||||
|
||||
Full-solution run: only pre-existing environment failures remain — `Host.IntegrationTests` (3,
|
||||
verified identical on the pre-change tree) and `Driver.AbLegacy.IntegrationTests` (4, docker
|
||||
fixture-gated, and notably these **fail rather than skip**, unlike every other fixture-gated suite).
|
||||
|
||||
---
|
||||
|
||||
*Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
|
||||
Every `path:line` reference was read from source. Items marked CANNOT VERIFY require hardware, a
|
||||
VPN'd gateway, or a live rig and are listed as unproven rather than assumed.*
|
||||
@@ -0,0 +1,34 @@
|
||||
# Isolated MQTT/Sparkplug live-gate overlay (Task 26).
|
||||
#
|
||||
# Runs the MAIN central pair ONLY, from this worktree's image, under its own
|
||||
# compose project + offset host ports, so the shared `otopcua-dev` rig and the
|
||||
# sibling driver worktrees that share `otopcua-host:dev` are untouched.
|
||||
#
|
||||
# docker build -f docker-dev/Dockerfile --target runtime -t otopcua-host:mqtt .
|
||||
# docker compose -p otopcua-mqtt -f docker-dev/docker-compose.yml \
|
||||
# -f docker-dev/docker-compose.mqtt.yml \
|
||||
# up -d sql migrator cluster-seed central-1 central-2
|
||||
#
|
||||
# AdminUI -> http://localhost:9210 (login disabled; auto-admin)
|
||||
# OPC UA -> opc.tcp://localhost:4850 (central-1) / :4851 (central-2)
|
||||
# SQL -> localhost,14350
|
||||
#
|
||||
# `!override` on every `ports` list: compose MERGES list-valued keys by
|
||||
# appending, so a plain re-declaration keeps the base file's "14330:1433" /
|
||||
# "4840:4840" and collides with the running `otopcua-dev` rig.
|
||||
services:
|
||||
|
||||
sql:
|
||||
ports: !override
|
||||
- "14350:1433"
|
||||
|
||||
central-1:
|
||||
image: otopcua-host:mqtt
|
||||
ports: !override
|
||||
- "4850:4840"
|
||||
- "9210:9000"
|
||||
|
||||
central-2:
|
||||
image: otopcua-host:mqtt
|
||||
ports: !override
|
||||
- "4851:4840"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Isolated MTConnect live-gate overlay (Task 21).
|
||||
#
|
||||
# Runs the MAIN central pair ONLY, from this worktree's image, under its own
|
||||
# compose project + offset host ports, so the shared `otopcua-dev` rig and the
|
||||
# three sibling driver worktrees that share `otopcua-host:dev` are untouched.
|
||||
# Mirrors how the mqtt-sparkplug worktree isolated itself (`otopcua-host:mqtt`,
|
||||
# project `otopcua-mqtt`, ports 9210/4850).
|
||||
#
|
||||
# docker build -f docker-dev/Dockerfile --target runtime -t otopcua-host:mtconnect .
|
||||
# docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml \
|
||||
# -f docker-dev/docker-compose.mtconnect.yml \
|
||||
# up -d sql migrator cluster-seed central-1 central-2
|
||||
#
|
||||
# AdminUI -> http://localhost:9220 (login disabled; auto-admin)
|
||||
# OPC UA -> opc.tcp://localhost:4860 (central-1) / :4861 (central-2)
|
||||
# SQL -> localhost,14340
|
||||
#
|
||||
# Both MAIN nodes run because `cluster-seed` declares MAIN as a 2-node cluster:
|
||||
# ConfigPublishCoordinator sources its expected-ack set from enabled ClusterNode
|
||||
# rows, so deploying with one node down fails at the apply deadline.
|
||||
#
|
||||
# Traefik is deliberately not started — central-1 publishes its AdminUI port
|
||||
# directly, which removes the :9200 round-robin that otherwise makes it
|
||||
# ambiguous which node served a request.
|
||||
|
||||
# NOTE: `!override` is required on every `ports` list. Compose MERGES list-valued
|
||||
# keys by appending, so a plain re-declaration would keep the base file's
|
||||
# "14330:1433" / "4840:4840" alongside the offsets and collide with the running
|
||||
# `otopcua-dev` rig ("Bind for 0.0.0.0:14330 failed: port is already allocated").
|
||||
services:
|
||||
|
||||
sql:
|
||||
ports: !override
|
||||
- "14340:1433"
|
||||
|
||||
central-1:
|
||||
image: otopcua-host:mtconnect
|
||||
ports: !override
|
||||
- "4860:4840"
|
||||
- "9220:9000"
|
||||
|
||||
central-2:
|
||||
image: otopcua-host:mtconnect
|
||||
ports: !override
|
||||
- "4861:4840"
|
||||
@@ -106,6 +106,28 @@ IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
|
||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, AkkaPort, GrpcPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 4053, 4056, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Backfill: GrpcPort on rows that predate the column (#493)
|
||||
--
|
||||
-- Every insert above is INSERT-if-not-exists, which is the right shape for a re-runnable seed
|
||||
-- but means a *new nullable* column never reaches rows created by an earlier version of this
|
||||
-- file. On a persisted volume that is exactly what happened when Phase 5 added GrpcPort: the
|
||||
-- six ClusterNode rows already existed, so they kept NULL, TelemetryNodeSource found no dial
|
||||
-- targets, and TelemetryDial:Mode=Grpc silently connected to nothing (throttled Warning per
|
||||
-- node — the code degrades gracefully, so nothing failed loudly).
|
||||
--
|
||||
-- Backfills NULL only. It must not overwrite a port an operator set deliberately on a
|
||||
-- long-lived dev volume; a NULL is the unambiguous "this row predates the column" marker.
|
||||
--
|
||||
-- AkkaPort deliberately has no equivalent here: it is NOT NULL with a default of 4053, so
|
||||
-- pre-existing rows already carry a usable value and there is nothing to backfill.
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
UPDATE dbo.ClusterNode
|
||||
SET GrpcPort = 4056 -- matches Telemetry__GrpcListenPort on all six nodes in docker-compose.yml
|
||||
WHERE GrpcPort IS NULL
|
||||
AND CreatedBy = 'docker-dev-seed';
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15)
|
||||
--
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Address Space
|
||||
|
||||
> ⚠️ **Accuracy warning (audited 2026-07-27) — this page is v2-era and contradicts the shipped v3
|
||||
> address space.** Corrections, in order of how badly they mislead:
|
||||
>
|
||||
> - **There is no "single custom namespace".** v3 exposes **two**:
|
||||
> `https://zb.com/otopcua/raw` (`s=<RawPath>`) and `https://zb.com/otopcua/uns`
|
||||
> (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`), registered together at
|
||||
> `OtOpcUaNodeManager.cs:45`. Identity authority is `V3NodeIds` + `AddressSpaceRealm`. The retired
|
||||
> `https://zb.com/otopcua/ns` survives only in a comment marking it retired (`V3NodeIds.cs:17`).
|
||||
> - **`Phase7Applier` / `Phase7Composer` / `GalaxyTagPlan` do not exist**, nor do the file paths cited
|
||||
> for them. Renamed by `40e8a23e` to `AddressSpaceApplier` / `AddressSpaceComposer`
|
||||
> (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`); the composition result type is
|
||||
> `AddressSpaceComposition` (`AddressSpaceComposer.cs:13`).
|
||||
> - **The `SystemPlatform` namespace kind is retired** — Galaxy is a standard Equipment-kind driver.
|
||||
> - **`GenericDriverNodeManager` is test scaffolding**, not a production dispatch path
|
||||
> (`GenericDriverNodeManager.cs:71`).
|
||||
> - **The §Rediscovery section is wrong**: nothing consumes `OnRediscoveryNeeded`
|
||||
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)), and the driver list is stale
|
||||
> (MQTT and MTConnect now raise it too).
|
||||
>
|
||||
> For the current design see the "v3 OPC UA Address Space (Batch 4)" section of `CLAUDE.md`,
|
||||
> `docs/Raw.md`, and `docs/Uns.md`.
|
||||
|
||||
Address-space construction is a two-layer system. The **driver-facing layer** is the streaming builder: a driver implements `ITagDiscovery.DiscoverAsync` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`) and emits `Folder` / `Variable` / `AddProperty` calls into an `IAddressSpaceBuilder` as it walks its backend — no buffering of the whole tree. `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) wraps that builder to capture alarm-condition sinks and routes alarm events from the driver to them. The **SDK materialization layer** turns the resulting node descriptions into live OPC UA nodes: `OpcUaPublishActor` drives the write-only `IOpcUaAddressSpaceSink`, whose production binding `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) forwards to `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`), a `CustomNodeManager2` subclass that owns the `FolderState` / `BaseDataVariableState` instances. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
|
||||
|
||||
## Root folder
|
||||
|
||||
@@ -114,6 +114,21 @@ source it from there.
|
||||
> hardcodes OPC-DA Good, `192`). Capturing real per-node quality will not persist until the gateway honors
|
||||
> the field — see `GatewayHistorianValueWriter`'s remarks (archreview 06/C-7).
|
||||
|
||||
> ⚠️ **Continuous value capture is code-complete but UNPROVEN IN PRODUCTION
|
||||
> ([#491](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/491)).** The recorder, the FasterLog
|
||||
> outbox, the gateway value-writer and the deploy-time historized-ref feed
|
||||
> (`AddressSpaceApplier.FeedHistorizedRefs` → `ActorHistorizedTagSubscriptionSink` →
|
||||
> `ContinuousHistorizationRecorder.UpdateHistorizedRefs`) are all wired, and the recorder converges to
|
||||
> the deployed ref set from the first deploy onward. What has **not** run is the end-to-end live gate
|
||||
> (deploy → dependency-mux value delta → outbox append → `WriteLiveValues`) plus a restart-convergence
|
||||
> test. **Reads and alarm-writes are live-validated; treat value capture as unproven rather than
|
||||
> absent.** See the "KNOWN LIMITATION 2" section of `CLAUDE.md`.
|
||||
>
|
||||
> **Related, separately open:** `EnsureTags` provisioning fires only for **newly-added** historized
|
||||
> tags (`AddressSpaceApplier` iterates the added set), so flipping an **existing** tag to
|
||||
> `"isHistorized": true` never provisions it — see
|
||||
> `docs/plans/2026-06-27-historian-gateway-integration-issues.md` Issue 5.
|
||||
|
||||
---
|
||||
|
||||
## Tag auto-provisioning (EnsureTags)
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Incremental Sync
|
||||
|
||||
> ⚠️ **Accuracy warning (audited 2026-07-27) — most of this page describes machinery that is retired
|
||||
> or was never wired.** Read it as v2-era design history, not as current behaviour.
|
||||
>
|
||||
> - **"Rebuild flow" (below) is not a production path.** It is built on `GenericDriverNodeManager`,
|
||||
> which is Core test scaffolding with **zero production references**
|
||||
> (`GenericDriverNodeManager.cs:71`). Its `_variablesByFullRef` / `_sourceByFullRef` fields do not
|
||||
> exist in `src/`.
|
||||
> - **Driver-backend rediscovery has no consumer.** Nothing in `src/Server/` or `src/Core/`
|
||||
> subscribes to `IRediscoverable.OnRediscoveryNeeded`; `DriverHostActor.HandleDiscoveredNodes`
|
||||
> (`DriverHostActor.cs:986-1002`) short-circuits unconditionally. The `ScopeHint` is read by
|
||||
> nothing. Drivers that raise it today: Galaxy, TwinCAT, MQTT/Sparkplug, MTConnect
|
||||
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
|
||||
> [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)).
|
||||
> - **The generation-publish path is retired.** `sp_PublishGeneration` and
|
||||
> `sp_ComputeGenerationDiff` are stubs that `RAISERROR`
|
||||
> (`20260715230637_V3Initial.StoredProcedures.cs:194`); `DraftRevisionToken` does not exist.
|
||||
>
|
||||
> **The one live rebuild path in v3** is the deploy artifact: `ConfigComposer` →
|
||||
> `Deployment.ArtifactBlob` → `DriverHostActor` apply → `AddressSpaceComposer` /
|
||||
> `AddressSpaceApplier` diff-apply.
|
||||
|
||||
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
|
||||
|
||||
## Driver-backend rediscovery — IRediscoverable
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ In v2 the Server and Admin processes were fused into a single role-gated `ZB.MOM
|
||||
- `CreateMasterNodeManager` constructs one `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) — a `CustomNodeManager2` subclass that owns the writable address space under the namespace `https://zb.com/otopcua/ns` and a single `OtOpcUa` root folder organized under the standard `Objects` folder. It is wrapped in a `MasterNodeManager` with no additional core managers.
|
||||
- `OtOpcUaSdkServer.NodeManager` exposes the live node manager after `StartAsync`, so the hosting layer can wrap it in a `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) and hand it to `OpcUaPublishActor`.
|
||||
|
||||
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (`DriverInstanceActor` → `OpcUaPublishActor`), and `OpcUaPublishActor` writes them into the node manager through the `IOpcUaAddressSpaceSink` seam. `OtOpcUaNodeManager.EnsureFolder` / `EnsureVariable` materialize the UNS folder + variable hierarchy; `WriteValue` / `WriteAlarmState` push runtime values and fire `ClearChangeMasks` so subscribed clients see updates.
|
||||
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (`DriverInstanceActor` → `OpcUaPublishActor`), and `OpcUaPublishActor` writes them into the node manager through the `IOpcUaAddressSpaceSink` seam. `OtOpcUaNodeManager.EnsureFolder` / `EnsureVariable` materialize the UNS folder + variable hierarchy; `WriteValue` / `WriteAlarmCondition` push runtime values and fire `ClearChangeMasks` so subscribed clients see updates.
|
||||
|
||||
The driver-agnostic walk that turns a driver's discovery into folder/variable calls lives in `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`): it walks `ITagDiscovery.DiscoverAsync` into an `IAddressSpaceBuilder`, captures alarm-condition sinks for variables flagged via `IVariableHandle.MarkAsAlarmCondition`, subscribes to `IAlarmSource.OnAlarmEvent`, and routes each alarm transition to the sink registered for its `SourceNodeId`.
|
||||
|
||||
@@ -48,7 +48,7 @@ The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpc
|
||||
|
||||
`OpcUaApplicationHost` subscribes to `SessionManager.ImpersonateUser` after `ApplicationInstance.Start`. The handler (`HandleImpersonation`) deals with the token types as follows:
|
||||
|
||||
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
|
||||
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapOpcUaUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
|
||||
- `AnonymousIdentityToken` and X.509 tokens → the handler returns without intervening, so the SDK's default validation stands.
|
||||
|
||||
Decryption failures and authenticator exceptions also map to `BadIdentityTokenRejected`.
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
| [Configuration.md](v1/Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — `OTOPCUA_GALAXY_*` env vars now live in mxaccessgw config) |
|
||||
| [Uns.md](Uns.md) | The global `/uns` master tree — manage Area/Line/Equipment/Tag/VirtualTag fleet-wide; replaces the per-cluster UNS/Equipment/Tags tabs |
|
||||
| [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyStateActor` (cluster-scoped singleton), `ServiceLevelCalculator`, `IRedundancyRoleView`, per-cluster meshes, Prometheus metrics |
|
||||
| [Reservations.md](Reservations.md) | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow |
|
||||
| [ServiceHosting.md](ServiceHosting.md) | Single fused `OtOpcUa.Host` binary install/uninstall with `OTOPCUA_ROLES` gating; the historian backend is the external HistorianGateway |
|
||||
| [StatusDashboard.md](StatusDashboard.md) | Pointer — superseded by [v2/admin-ui.md](v2/admin-ui.md) |
|
||||
|
||||
+96
-43
@@ -1,67 +1,120 @@
|
||||
# Read/Write Operations
|
||||
|
||||
The v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value.
|
||||
> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`.
|
||||
> The previous revision described a v2-era `DriverNodeManager` with per-variable `OnReadValue` hooks,
|
||||
> an `AuthorizationGate`/`WriteAuthzPolicy` ACL pair and a `NodeSourceKind` dispatch switch. **None of
|
||||
> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef`
|
||||
> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing
|
||||
> like the way it was documented. See `deferment.md` §3.1 and §7.
|
||||
|
||||
## Driver vs virtual dispatch
|
||||
## The shape of it: push for reads, pull for writes
|
||||
|
||||
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend:
|
||||
The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the
|
||||
single most important thing on this page:
|
||||
|
||||
- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc).
|
||||
- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.
|
||||
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim.
|
||||
- **Reads never reach a driver.** There is no `Read` override and no `OnReadValue` / `OnSimpleReadValue`
|
||||
handler anywhere in the node manager. Driver values are *pushed in* from the Akka actor layer
|
||||
(`DriverInstanceActor` polls or subscribes, `DriverHostActor` fans the value to the raw NodeId and
|
||||
every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the
|
||||
cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail
|
||||
with a device error — it returns whatever quality was last pushed.
|
||||
- **Writes are synchronous pull-through.** A client write runs `OnWriteValue` → role gate → driver.
|
||||
|
||||
ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.
|
||||
Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the
|
||||
invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The
|
||||
`OpcUaServer` project does not reference it at all.
|
||||
|
||||
## OnReadValue
|
||||
## Read path
|
||||
|
||||
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
|
||||
1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace.
|
||||
2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed.
|
||||
3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted
|
||||
session — including an Anonymous one — can read every node in the address space. The only gating is
|
||||
the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not
|
||||
a per-user decision.
|
||||
|
||||
1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`.
|
||||
2. The node's `NodeId.Identifier` is used directly as the driver-side full reference — it matches `DriverAttributeInfo.FullName` registered at discovery time.
|
||||
3. (Phase 6.2) If an `AuthorizationGate` + `NodeScopeResolver` are wired, the gate is consulted first via `IsAllowed(identity, OpcUaOperation.Read, scope)`. A denied read never hits the driver.
|
||||
4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144).
|
||||
5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`.
|
||||
Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see
|
||||
[docs/security.md](security.md) § Data-Plane Authorization).
|
||||
|
||||
The hook is synchronous — the async invoker call is bridged with `AsTask().GetAwaiter().GetResult()` because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline.
|
||||
## Write path — `OnEquipmentTagWrite`
|
||||
|
||||
## OnWriteValue
|
||||
The handler is attached in `EnsureVariable` **only when the tag was materialized `writable`**, and
|
||||
re-attached or cleared by `UpdateTagAttributes` on an in-place edit. A non-writable node has no
|
||||
handler, so the SDK rejects the write before any of this runs.
|
||||
|
||||
`OnWriteValue` follows the same shape with two additional concerns: authorization and idempotence.
|
||||
1. **Role gate.** `EvaluateEquipmentWriteGate(identity, gatewayWired)` requires the session identity to
|
||||
be a `RoleCarryingUserIdentity` carrying the `WriteOperate` role. No identity or no role ⇒
|
||||
`BadUserAccessDenied`, fail-closed. Gate passed but no write gateway wired (admin-only node,
|
||||
pre-boot) ⇒ `BadNotWritable`.
|
||||
- This is a **server-wide** check. It takes no node and no realm: a session that may write one tag
|
||||
may write every writable tag on that node.
|
||||
- `WriteTune` and `WriteConfigure` are **never checked**. `OpcUaDataPlaneRoles` declares only
|
||||
`WriteOperate` and `AlarmAck`; the classification tiers exist in the permission vocabulary but no
|
||||
code path reads them.
|
||||
2. **Optimistic local apply.** The new value is written to the node so subscribers see it immediately.
|
||||
3. **Realm-qualified dispatch.** `RealmOf(node.NodeId)` selects Raw or UNS, and the value routes to the
|
||||
backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the
|
||||
same driver ref, so a write via either one reaches the same device point.
|
||||
4. **Write-outcome self-correction.** If the device write fails, the optimistic value is **reverted**
|
||||
on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write
|
||||
cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so
|
||||
it can never surface a write failure.)
|
||||
|
||||
### Authorization (two layers)
|
||||
`OnWriteValue` is invoked by the SDK **while holding the node-manager `Lock`**, so the handler must not
|
||||
block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation.
|
||||
|
||||
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`).
|
||||
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
|
||||
## Alarm method calls
|
||||
|
||||
### Dispatch
|
||||
|
||||
`_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …)` honors the `WriteIdempotentAttribute` semantics per decisions #44-45 and #143:
|
||||
|
||||
- `isIdempotent = true` (tag flagged `WriteIdempotent` in the Config DB) → runs through the standard `DriverCapability.Write` pipeline; retry may apply per the tier configuration.
|
||||
- `isIdempotent = false` (default) → the invoker builds a one-off pipeline with `RetryCount = 0`. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's.
|
||||
|
||||
The `_writeIdempotentByFullRef` lookup is populated at discovery time from the `DriverAttributeInfo.WriteIdempotent` field.
|
||||
|
||||
### Per-write status
|
||||
|
||||
`IWritable.WriteAsync` returns `IReadOnlyList<WriteResult>` — one numeric `StatusCode` per requested write. A non-zero code is surfaced directly to the client; exceptions become `BadInternalError`. The OPC UA stack's pattern of batching per-service is preserved through the full chain.
|
||||
|
||||
## Array element writes
|
||||
|
||||
Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA stack hands the dispatch an unwrapped `NumericRange` on the `indexRange` parameter of `OnWriteValue`; `DriverNodeManager` passes the full `value` object to `IWritable.WriteAsync` and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today.
|
||||
Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
|
||||
are gated by a second role check requiring `AlarmAck` — one role covering all five methods; the
|
||||
`operation` argument is passed to the router but the gate does not consult it. Scripted alarms go
|
||||
through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed
|
||||
with `BadUserAccessDenied`.
|
||||
|
||||
## HistoryRead
|
||||
|
||||
`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/v1/HistoricalDataAccess.md`.
|
||||
Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`,
|
||||
`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed
|
||||
reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`).
|
||||
This is not a per-driver `IHistoryProvider` dispatch.
|
||||
|
||||
**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the
|
||||
node-manager `Lock` while invoking them, so these paths may await.
|
||||
|
||||
See [docs/Historian.md](Historian.md).
|
||||
|
||||
## Failure isolation
|
||||
|
||||
Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node `BadInternalError` — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process.
|
||||
Exceptions in a driver capability call are logged and converted to a per-node bad status rather than
|
||||
unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the
|
||||
same process.
|
||||
|
||||
## What is *not* enforced anywhere
|
||||
|
||||
Collected here because the previous revision of this page claimed several of these:
|
||||
|
||||
| Surface | Check |
|
||||
|---|---|
|
||||
| Read, Browse, TranslateBrowsePaths | none |
|
||||
| CreateMonitoredItems / Subscribe / TransferSubscriptions | none |
|
||||
| HistoryRead (all four variants) | none |
|
||||
| Non-Value attribute writes | none (the handler is on `OnWriteValue`) |
|
||||
| Value write | `WriteOperate` role, server-wide |
|
||||
| Alarm methods | `AlarmAck` role, server-wide |
|
||||
| Per-node ACLs (`NodeAcl` / `PermissionTrie`) | **authored and deployed, never evaluated** |
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — address-space population and alarm routing during discovery
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`) that gates Read/Write/Subscribe per the session's resolved LDAP groups
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — the live node manager;
|
||||
`EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four
|
||||
HistoryRead overrides
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`,
|
||||
`Security/OpcUaDataPlaneRoles.cs` — how roles reach the gates
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — materialization; where the write
|
||||
handler is attached per realm
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — the push side: value fan-out to
|
||||
raw + UNS NodeIds
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — driver-side resilience pipeline
|
||||
(**not** on the OPC UA read path)
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — the permission trie + evaluator, built and
|
||||
unit-tested but with **zero production consumers**
|
||||
|
||||
@@ -128,6 +128,39 @@ transports. ScadaBridge itself has since closed that gap with this identical
|
||||
interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a
|
||||
later hardening pass. See the design doc's superseded note for the full history.
|
||||
|
||||
## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493)
|
||||
|
||||
`GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that
|
||||
already existed. On an upgraded deployment every `ClusterNode` row therefore carries `NULL`,
|
||||
central finds **no dial targets**, and flipping `TelemetryDial:Mode` to `Grpc` connects to
|
||||
nothing. Fresh installs are unaffected. `AkkaPort` did not have this problem only because it is
|
||||
`NOT NULL` with a default of 4053.
|
||||
|
||||
Nothing fails loudly, which is what makes this worth stating up front — the code degrades
|
||||
gracefully, once per node, throttled:
|
||||
|
||||
```
|
||||
ClusterNode <id> has no GrpcPort; it exposes no telemetry stream and is skipped in this dial refresh
|
||||
(further skips of this node are silent)
|
||||
```
|
||||
|
||||
Other symptoms: no `ESTABLISHED` sockets on the telemetry port (a `LISTEN` but nothing else in
|
||||
`docker exec <node> cat /proc/net/tcp6`), and the `/alerts` / `/script-log` pill never turning
|
||||
live in `Grpc` mode.
|
||||
|
||||
**Before flipping any node to `Grpc`**, set each driver node's `GrpcPort` to that node's own
|
||||
`Telemetry:GrpcListenPort` — via the AdminUI node editor, or directly:
|
||||
|
||||
```sql
|
||||
UPDATE dbo.ClusterNode SET GrpcPort = <that node's Telemetry:GrpcListenPort> WHERE NodeId = '<node>';
|
||||
```
|
||||
|
||||
There is deliberately **no EF data migration** backfilling this. The correct value is that node's
|
||||
own listener port, which is per-deployment configuration the database cannot derive; a migration
|
||||
could only invent one, and a *wrong* dial target is worse than the `NULL` the dialer already skips
|
||||
explicitly. The docker-dev seed does backfill (`GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'`
|
||||
→ 4056) because there the port is fixed by `docker-compose.yml` and therefore actually knowable.
|
||||
|
||||
## Reconnect and reliability
|
||||
|
||||
Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per
|
||||
|
||||
@@ -70,7 +70,7 @@ Project root files:
|
||||
| Capability | Implementation entry point |
|
||||
|------------|---------------------------|
|
||||
| `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` |
|
||||
| `IRediscoverable` | `Browse/DeployWatcher.cs` |
|
||||
| `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) |
|
||||
| `IReadable` | `Runtime/GalaxyMxSession.cs` |
|
||||
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
|
||||
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) |
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
# MTConnect Driver
|
||||
|
||||
Getting-started guide for the MTConnect Agent driver (P1 Agent MVP). This is
|
||||
the short path — for the full design rationale read
|
||||
[`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md)
|
||||
and the build-vs-plan record in
|
||||
[`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md); for the
|
||||
fixture recipe read
|
||||
[`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
|
||||
(not duplicated here).
|
||||
|
||||
> **Live gate:** see the LIVE-GATE RESULT note in the plan's Task 21
|
||||
> (`docs/plans/2026-07-24-mtconnect-driver.md`) for the docker-dev `/run` verification outcome
|
||||
> (browse picker / typed editor / deploy / read / subscribe against the real Agent fixture).
|
||||
|
||||
## What it talks to
|
||||
|
||||
A **MTConnect Agent** — the vendor-neutral read-only telemetry endpoint that
|
||||
front-ends a machine tool (or fronts an adapter that itself talks to the
|
||||
machine over SHDR). The driver speaks plain HTTP + XML against the Agent's
|
||||
three standard REST paths:
|
||||
|
||||
- `/probe` — the static device model (Device → Component → DataItem tree)
|
||||
- `/current` — a snapshot of every DataItem's latest observation
|
||||
- `/sample` — a `multipart/x-mixed-replace` long-poll stream of observation
|
||||
deltas, keyed by a monotonic sequence number
|
||||
|
||||
v1 is **agent-first and read-only** — Discover + Read + Subscribe, no Write —
|
||||
because the mainstream Agent surface has no "set value" operation.
|
||||
|
||||
## Built vs. planned — read this before trusting the design doc's §2
|
||||
|
||||
The design doc ([`2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md))
|
||||
picked the **TrakHound `MTConnect.NET-Common` / `-HTTP`** NuGet packages (MIT,
|
||||
`netstandard2.0`) as the primary path, with a hand-rolled fallback. **The
|
||||
hand-rolled fallback is what shipped.** Task 6/7 of the implementation plan
|
||||
found by reflection + live invocation that the pinned TrakHound packages
|
||||
ship **no XML formatter** (`Document Formatter Not found for "xml"` — the
|
||||
formatter lives in a separate `MTConnect.NET-XML` package the design didn't
|
||||
pin) and that `MTConnectHttpClientStream` exposes no injectable `HttpClient`
|
||||
handler, so it cannot be unit-tested behind the driver's `IMTConnectAgentClient`
|
||||
seam. Both package references were dropped. There is **no TrakHound
|
||||
dependency anywhere in the shipped driver** — probe/current/sample parsing is
|
||||
hand-rolled `System.Xml.Linq` plus a multipart boundary reader, entirely
|
||||
behind `IMTConnectAgentClient`. See the CORRECTION block under Task 0 of the
|
||||
implementation plan for the full record.
|
||||
|
||||
**Namespace-agnostic parsing.** Every parser matches XML elements on
|
||||
`LocalName` only, ignoring the document's XML namespace entirely, so
|
||||
MTConnect 1.3 through 2.x documents all parse without a schema-version
|
||||
branch. This is deliberate, not sloppy: a real Agent injects vendor-extension
|
||||
`Component`s in a foreign namespace whose child `DataItems` elements inherit
|
||||
the *default* namespace, and namespace-strict matching would silently drop
|
||||
the whole vendor component (and every DataItem beneath it) rather than fail
|
||||
loudly. Attributes are read **unqualified** for the same reason — so an
|
||||
`xsi:type` attribute is never mistaken for a DataItem's own `type`.
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/` | net10.0 | In-process driver — `MTConnectDriver`, the hand-rolled `IMTConnectAgentClient` (probe/current/sample), the observation index, the factory |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/` | net10.0 | `MTConnectDriverOptions`, `MTConnectTagDefinition`, and the pure `MTConnectDataTypeInference` table shared by the driver, browse-commit, and the AdminUI typed editor |
|
||||
|
||||
No `.Browser` project — browse comes free from the Wave-0 universal
|
||||
discovery browser (see [Browse](#browse--free-via-the-universal-browser)
|
||||
below).
|
||||
|
||||
## Capability surface
|
||||
|
||||
`MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
|
||||
|
||||
Deliberately **not** `IWritable` — the Agent surface is read-only by design.
|
||||
Write-back exists only via optional, rarely-deployed MTConnect *Interfaces*
|
||||
(a request/response handshake, not a "set value" operation), and is out of
|
||||
scope for this build — see [Deferred](#deferred-not-in-this-build).
|
||||
|
||||
| Capability | Path | Notes |
|
||||
|------------|------|-------|
|
||||
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
|
||||
| `IReadable` | `ReadAsync` → one `/current` per call | **Not the production data path** — see below |
|
||||
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
|
||||
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
|
||||
**`IReadable.ReadAsync` is never called by the running server.** The
|
||||
production data plane is entirely `ISubscribable` — `DriverInstanceActor`
|
||||
subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the
|
||||
Client CLI (`... read -n ...`) and for the unit/integration suites; it is
|
||||
correct and tested, just not on the hot path.
|
||||
|
||||
## Minimum deployment
|
||||
|
||||
```jsonc
|
||||
"Drivers": {
|
||||
"mtconnect-1": {
|
||||
"Type": "MTConnect",
|
||||
"Config": {
|
||||
"AgentUri": "http://10.100.0.35:5000",
|
||||
"DeviceName": null,
|
||||
"RequestTimeoutMs": 5000,
|
||||
"SampleIntervalMs": 1000,
|
||||
"SampleCount": 1000,
|
||||
"HeartbeatMs": 10000,
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||||
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
|
||||
"Tags": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RawTags[]` is not authored by hand — `DriverDeviceConfigMerger` injects it
|
||||
at deploy time from the tags authored on the `/raw` tree.
|
||||
|
||||
### Config keys
|
||||
|
||||
Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`,
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`):
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
|
||||
| `DeviceName` | `null` | Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent |
|
||||
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
|
||||
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
|
||||
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param |
|
||||
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
|
||||
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
|
||||
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
|
||||
| `Tags[]` | `[]` | Pre-v3 / CLI authoring surface — one `MTConnectTagDefinition` per DataItem `id` |
|
||||
| `RawTags[]` | `[]` (deploy-injected) | The v3 data-plane binding — see below |
|
||||
|
||||
**All timing knobs are validated strictly positive** at `InitializeAsync`
|
||||
(`RequirePositive`, mirroring the arch-review 01/S-6 lesson: a `0` timeout
|
||||
does not mean "wait forever," it faults the driver). This applies to
|
||||
`RequestTimeoutMs`, `HeartbeatMs`, `SampleIntervalMs`, `SampleCount`, and
|
||||
(when the probe is enabled) `Probe.Interval` / `Probe.Timeout`.
|
||||
|
||||
**No save-time gate on a blank `AgentUri` in the AdminUI.** The driver form
|
||||
renders an inline validation notice (`_form.Validate()`), but
|
||||
`DriverConfigModal.SaveAsync` has no per-form validation seam to block the
|
||||
Save button on it — no sibling driver form has one either. A blank
|
||||
`AgentUri` saves cleanly and fails at deploy with the driver's own error
|
||||
message, not at authoring time.
|
||||
|
||||
## Data plane
|
||||
|
||||
### FullName == DataItem `id`
|
||||
|
||||
`FullName` (both `MTConnectTagDefinition.FullName` and every `RawTags[]`
|
||||
blob's identifier) is the MTConnect `DataItem@id` attribute — the value the
|
||||
universal browser commits and the key the driver resolves reads/subscribes
|
||||
against. `MTConnectTagConfigModel.FromJson` (the AdminUI typed editor) tries
|
||||
three spellings in order — `fullName` → `dataItemId` → `address` — and takes
|
||||
the first non-blank one, normalising onto `fullName` on save. `address` is
|
||||
accepted because that's the field name `RawBrowseCommitMapper` writes for a
|
||||
driver with no typed editor path, so a browse-committed tag stays readable
|
||||
even before the editor round-trips it.
|
||||
|
||||
### Coercion-type precedence
|
||||
|
||||
The driver keys its data plane by **RawPath** (the v3 raw-tag identity), not
|
||||
by DataItem id directly, resolving RawPath → dataItemId internally. Each raw
|
||||
tag's coercion type (the `DriverDataType` its Agent observation is parsed
|
||||
into) is resolved in this order, first non-null wins:
|
||||
|
||||
1. The `RawTags[]` blob's `driverDataType` or `dataType` field (both
|
||||
spellings accepted — the typed editor writes `dataType`, the driver's own
|
||||
`tags[]` shape uses `driverDataType`).
|
||||
2. A matching `Tags[]` entry naming the same DataItem id.
|
||||
3. The Agent's own `/probe` declaration, via `MTConnectDataTypeInference.Infer`
|
||||
(category/type/units/representation → `DriverDataType`).
|
||||
4. `DriverDataType.String` — the coercion that cannot fail.
|
||||
|
||||
### Quality mapping
|
||||
|
||||
`UNAVAILABLE` — MTConnect's one explicit "I have no value" sentinel — maps to
|
||||
**`BadNoCommunication`** (`0x80310000`). This is deliberately distinct from
|
||||
the fleet-standard `BadCommunicationError` (`0x80050000`, used elsewhere for
|
||||
the driver's *own* transport failure to the Agent): `UNAVAILABLE` means the
|
||||
Agent is reachable and answered, it just has no device-backed value for that
|
||||
item. A `CONDITION` observation's value is taken from its **element name**
|
||||
(`<Normal/>` → the string `"Normal"`, `<Fault/>` → `"Fault"`); a `<Unavailable/>`
|
||||
condition element normalises onto the same `UNAVAILABLE` sentinel as every
|
||||
other category so one comparison covers all three.
|
||||
|
||||
Other status codes an observation can surface:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all |
|
||||
| `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent |
|
||||
| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a `TIME_SERIES` vector, or a structured `DATA_SET`/`TABLE` observation (deferred to P1.5; see [Known limitations](#known-limitations)) |
|
||||
| `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet |
|
||||
| `BadNodeIdUnknown` | DataItem id is neither authored nor ever observed |
|
||||
|
||||
### Subscribe — the `/sample` pump
|
||||
|
||||
One shared `/sample` long-poll stream per driver instance (the Agent streams
|
||||
the whole device model regardless of which subset is subscribed, so
|
||||
per-tag streams would be wasted round-trips), run under a heartbeat
|
||||
watchdog — `HttpClient.Timeout` cannot bound a long-lived stream, so a
|
||||
missing chunk **and** missing keep-alive heartbeat within
|
||||
`HeartbeatMs × N` is what detects a frozen peer.
|
||||
|
||||
Ring-buffer overflow (the Agent's circular observation buffer wrapped past
|
||||
what the driver's cursor expects) is detected **two ways**, both triggering a
|
||||
`/current` re-baseline before the stream resumes:
|
||||
|
||||
1. `IMTConnectAgentClient.IsSequenceGap` — the next chunk's `firstSequence`
|
||||
is newer than the driver's expected cursor.
|
||||
2. An `MTConnectError` document reporting `OUT_OF_RANGE` — real Agents return
|
||||
this both under HTTP 200 (a normal MTConnect error document) and as a bare
|
||||
HTTP 400, and the client handles both.
|
||||
|
||||
**An Agent `instanceId` change means the Agent restarted**, and is checked
|
||||
**before** the sequence-gap check (a restart also usually trips the gap, so
|
||||
the two need disambiguating, not just OR-ing together). On a changed
|
||||
`instanceId` the driver clears its cached probe model and raises
|
||||
`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI
|
||||
`/hosts` page — see [Known limitations](#known-limitations) for what that does
|
||||
and does not do.
|
||||
|
||||
## Browse — free via the universal browser
|
||||
|
||||
MTConnect ships **no bespoke browser project**. Setting
|
||||
`ITagDiscovery.SupportsOnlineDiscovery => true` is the entire integration:
|
||||
the Wave-0 `DiscoveryDriverBrowser` sees a driver whose `TryCreate` succeeds
|
||||
and whose instance reports online discovery, renders the AdminUI **Browse**
|
||||
button, constructs the driver, runs `InitializeAsync` (the `/probe` connect)
|
||||
+ `DiscoverAsync` into a `CapturingAddressSpaceBuilder`, then tears the
|
||||
throwaway instance down. Each captured leaf's NodeId is the DataItem `id`,
|
||||
committed directly as `TagConfig.FullName` on pick. See
|
||||
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md).
|
||||
|
||||
## CONDITION modelling (v1: plain String)
|
||||
|
||||
Each `CONDITION` DataItem materializes as a `String` variable whose value is
|
||||
the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`.
|
||||
There is no native OPC UA Part 9 alarm plumbing in this build;
|
||||
`DriverAttributeInfo.IsAlarm = true` is still stamped on a CONDITION leaf so
|
||||
the browse side-panel flags it and a future upgrade to native alarms doesn't
|
||||
need re-authoring. See [Deferred](#deferred-not-in-this-build).
|
||||
|
||||
## Known limitations
|
||||
|
||||
These are real, not placeholders — read them before relying on the driver
|
||||
for anything beyond values-and-conditions.
|
||||
|
||||
1. **A restarted Agent prompts an operator; it does not re-shape the address
|
||||
space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded`
|
||||
(2026-07-27), so a changed `instanceId` raises a "re-browse" chip against
|
||||
this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors
|
||||
raw tags through the `/raw` browse-commit flow, so until an operator
|
||||
re-browses the Agent and commits, any DataItem that appeared or vanished is
|
||||
not reflected in the served tree. A runtime graft was deliberately rejected
|
||||
— it would materialise nodes nobody approved and no deployment artifact
|
||||
records.
|
||||
|
||||
**`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has
|
||||
no production call site and nothing writes a `DriverHostStatus` row. That
|
||||
half remains a fleet-wide gap affecting every driver that implements it
|
||||
(Gitea #521).
|
||||
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
|
||||
routing.** One driver instance owns exactly one Agent client scoped by
|
||||
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
|
||||
per-tag `RawTagEntry.DeviceName` field the v3 raw-tag identity carries is
|
||||
never read by the driver. Two `/raw` Devices authored under one MTConnect
|
||||
driver instance both resolve to the same one Agent connection. Real
|
||||
per-device routing (a client per device) would be a design change, not a
|
||||
bug fix.
|
||||
3. **Nothing validates a raw tag's declared OPC UA `DataType` against the
|
||||
blob's coercion type.** The `/probe`-sourced inference
|
||||
(`MTConnectDataTypeInference`) narrows how often an author picks a wrong
|
||||
type, but it doesn't close the gap — an authored `DataType` mismatched
|
||||
against the actual Agent observation still surfaces as `BadTypeMismatch`
|
||||
/`BadOutOfRange` at runtime rather than at authoring time.
|
||||
4. **`sampleCount` is not a legal `DataItem` attribute.** It belongs on a
|
||||
`TIME_SERIES` *observation*, not the device-model declaration. A real
|
||||
Agent that meets `sampleCount` on a `DataItem` declaration logs `The
|
||||
following keys were present and not expected: sampleCount` and **drops
|
||||
the entire data item** from the served model. A live TIME_SERIES tag
|
||||
therefore always resolves to `ArrayDim = null` (variable-length array) in
|
||||
practice — the fixture's canned `probe.xml`/`Devices.xml`, which does
|
||||
declare a `sampleCount`, is unrealistic on this point and exists only to
|
||||
pin the parsing rule itself.
|
||||
5. **CONDITION is a plain `String`, not a native Part 9 alarm** (see above).
|
||||
6. **`TIME_SERIES` arrays and structured `DATA_SET`/`TABLE` observations
|
||||
surface as `BadNotSupported`**, not an approximation — see the Quality
|
||||
mapping table.
|
||||
|
||||
## Deferred (not in this build)
|
||||
|
||||
- **Write-back (MTConnect Interfaces).** Design
|
||||
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1):
|
||||
the mainstream Agent surface is read-only by design; Interfaces is a rare,
|
||||
optional request/response handshake that would mislead if modelled as
|
||||
`IWritable`. Revisit only on a concrete deployment need.
|
||||
- **P1.5 fast-follow** (design
|
||||
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)):
|
||||
CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy
|
||||
native-alarm pattern); `TIME_SERIES` SAMPLE arrays materialized as real
|
||||
OPC UA arrays; EVENT controlled-vocabulary values → OPC UA enumerations.
|
||||
- **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"`
|
||||
switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses
|
||||
auto-discovery (no device model without an Agent in front, so
|
||||
`SupportsOnlineDiscovery` would have to report `false` and tags would be
|
||||
authored by hand, like Modbus). Niche; not built.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
|
||||
(canned-XML fixtures, no network) — 491/491 at time of writing. Covers
|
||||
discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
|
||||
`UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping,
|
||||
multipart chunk framing, and ring-buffer re-baseline paging (both the
|
||||
sequence-gap and the `OUT_OF_RANGE` legs).
|
||||
- **Integration tests** —
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated
|
||||
on `MTCONNECT_AGENT_ENDPOINT` (default `http://10.100.0.35:5000`), skips
|
||||
cleanly (12 Skipped) when the fixture is unreachable, 12/12 against a real
|
||||
Agent. Read the fixture's own
|
||||
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
|
||||
for the full recipe — summary only, here:
|
||||
- Image is **`mtconnect/agent:2.7.0.12`** — not `mtconnect/cppagent`, which
|
||||
does not exist on Docker Hub (the design's original assumption).
|
||||
- The stack is **two services**: the Agent plus a stdlib-only SHDR adapter
|
||||
(`Docker/adapter.py`, on `python:3.13-alpine`). An Agent with no adapter
|
||||
reports every observation `UNAVAILABLE` forever, which proves nothing.
|
||||
- Deployed at `/opt/otopcua-mtconnect/` on the shared docker host
|
||||
(`10.100.0.35`, `project=lmxopcua` label). Endpoint
|
||||
`http://10.100.0.35:5000`.
|
||||
- `agent.cfg` must be **pure ASCII** — one non-ASCII byte anywhere,
|
||||
including a comment, makes the config parser reject the whole file with
|
||||
a bare `Failed / Stopped at line: N`.
|
||||
- Bring-up: `lmxopcua-fix sync mtconnect` then `lmxopcua-fix up mtconnect`
|
||||
(PowerShell helper, Windows-only) — or, directly on the docker host,
|
||||
`rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run
|
||||
`docker compose up -d --wait`.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md) — full design: capability wiring, browse reconciliation, typed-editor spec, resilience/timeout rules, phasing
|
||||
- [`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md) — the executable implementation plan and the task-by-task build record, including where it diverged from the design (TrakHound → hand-rolled)
|
||||
- [`docs/plans/2026-07-24-driver-expansion-tracking.md`](../plans/2026-07-24-driver-expansion-tracking.md) — Wave-2 program tracking
|
||||
- [Docker fixture README](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — the authoritative fixture recipe (seeded device model, endpoint, Mac AirPlay port-5000 gotcha)
|
||||
@@ -0,0 +1,133 @@
|
||||
# MQTT test fixture
|
||||
|
||||
Coverage map + gap inventory for the MQTT driver's harness.
|
||||
|
||||
**TL;DR:** the unit suite (581 tests) carries the mapping, indexing, Sparkplug state-machine and
|
||||
failure-classification logic; the live suite (15 tests) proves the parts only a real broker can prove
|
||||
— TLS, real authentication, CA pinning in both directions, retained-message seeding, that a rejected
|
||||
CONNACK is actually surfaced, and the Sparkplug birth/alias/rebirth/death path against a real
|
||||
edge-node simulator.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
`eclipse-mosquitto:2.0.22` plus a second container of the same image running `publisher/publish.sh`
|
||||
(the image ships `mosquitto_pub`, so no bespoke image is needed), plus — behind the `sparkplug`
|
||||
compose profile — **`otopcua-sparkplug-sim`**, a project-owned C# Sparkplug-B edge-node simulator
|
||||
(`tests/Drivers/….Driver.Mqtt.IntegrationTests/SparkplugSimulator/`). Compose lives at
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/`; the deployed stack is
|
||||
`/opt/otopcua-mqtt` on the shared Docker host.
|
||||
|
||||
**Two authenticated listeners, no anonymous fallback** (`allow_anonymous false`) — deliberate: an
|
||||
anonymous broker would let a broken auth config pass every test.
|
||||
|
||||
| Listener | Endpoint | Env var |
|
||||
|---|---|---|
|
||||
| TLS + auth | `10.100.0.35:8883` | `MQTT_FIXTURE_ENDPOINT` |
|
||||
| plaintext + auth | `10.100.0.35:1883` | `MQTT_FIXTURE_PLAIN_ENDPOINT` |
|
||||
|
||||
`MqttFixture` gates the suite on `MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_USERNAME` /
|
||||
`MQTT_FIXTURE_PASSWORD` (+ `MQTT_FIXTURE_CA_CERT`, `MQTT_FIXTURE_TOPIC_PREFIX`) and **skips cleanly**
|
||||
when they are absent, so the suite is safe to run offline.
|
||||
|
||||
**Unlike every other fixture it needs generated material first** — `./gen-fixture-material.sh` writes
|
||||
the gitignored `secrets/` (password file, CA, server cert/key). The broker will not start without it.
|
||||
Bring-up and the CA-fetch recipe are in [`infra/README.md`](../../infra/README.md) §3.
|
||||
|
||||
### Published topics
|
||||
|
||||
| Topic | Shape | Retain | Why it exists |
|
||||
|---|---|---|---|
|
||||
| `otopcua/fixture/retained/seed` | `{"value":42.5,…}` | ✅ **published exactly once** | The only way to prove retained *seeding*. On a republished topic a retained seed is indistinguishable from a fast live publish |
|
||||
| `otopcua/fixture/line1/temperature` | `{"value":…,"unit":"C","seq":n}` | ✅ | JSON + JSONPath extraction |
|
||||
| `otopcua/fixture/line1/counter` | `{"value":n}` | ✅ | Monotonic, for ordering |
|
||||
| `otopcua/fixture/line1/state` | `RUNNING` (bare text) | ✅ | Non-JSON `Raw`/`Scalar` payloads |
|
||||
| `otopcua/fixture/noise/chatter` | JSON | ❌ | **Never authored as a tag** — a broker carrying unauthored traffic is the normal case and the driver must stay silent about it |
|
||||
|
||||
## What it actually covers
|
||||
|
||||
The 7 live tests: TLS+auth connect; connect pinned to the fixture CA; connect pinned to a *foreign*
|
||||
CA is **rejected** (the falsifiability control — a pinning test that only ever passes proves nothing);
|
||||
wrong password is rejected **and surfaced** (the MQTTnet-5 silent-`Healthy` defect); plain
|
||||
subscribe→value under a RawPath; retained message seeds a value on subscribe; and an unauthored topic
|
||||
stays silent while an authored tag keeps flowing.
|
||||
|
||||
### The Sparkplug simulator
|
||||
|
||||
Group **`OtOpcUaSim`**; edge nodes **`EdgeA`** and **`EdgeB`**; `EdgeA` additionally publishes device
|
||||
**`Filler1`**. Node metrics `Temperature` (Float), `Pressure` (Double), `Count` (Int32), `Running`
|
||||
(Boolean), `Serial` (String) plus the mandatory `bdSeq` and **`Node Control/Rebirth`** — the last one
|
||||
deliberately carries a `/`, so it exercises the "a metric name is not a topic segment" rule. Device
|
||||
metrics: `Temperature` (Float), `FillCount` (Int64), `Jammed` (Boolean). Cadence ~2 s.
|
||||
|
||||
It **subscribes to `spBv1.0/OtOpcUaSim/NCMD/{node}`** and re-births on a rebirth request, so it
|
||||
exercises the real NCMD round trip rather than simulating it. Births are not retained (per spec), so
|
||||
`docker restart otopcua-sparkplug-sim` is the way to force a fresh birth on demand:
|
||||
|
||||
```bash
|
||||
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mqtt && docker compose --profile sparkplug up -d'
|
||||
ssh dohertj2@10.100.0.35 'docker restart otopcua-sparkplug-sim' # force a birth
|
||||
ssh dohertj2@10.100.0.35 'docker logs -f otopcua-sparkplug-sim'
|
||||
```
|
||||
|
||||
Live-gated end-to-end (2026-07-24, P1 milestone): deployed on the docker-dev rig against this fixture,
|
||||
both tags resolved and served changing values through OPC UA, and the AdminUI `#`-observation picker
|
||||
rendered the real topic tree. Re-gated for P2 (2026-07-25) against the Sparkplug simulator — see
|
||||
[Mqtt.md](Mqtt.md).
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. Sparkplug primary-host STATE, and write-through
|
||||
The simulator never observes a Host Application `STATE`, because the driver never publishes one
|
||||
(`ActAsPrimaryHost` is unimplemented). Nor is there any DCMD/NCMD **command** coverage: rebirth is the
|
||||
only NCMD the driver ever sends, and `IWritable` is deferred.
|
||||
|
||||
Sparkplug **seq-gap injection** is unit-proven only — the simulator always publishes a well-formed
|
||||
monotonic `seq`, so no live test drives the gap → rebirth path end-to-end.
|
||||
|
||||
### 2. Broker-side failure injection
|
||||
No test kills the broker mid-session, partitions the network, or forces a SUBACK failure against a
|
||||
real broker. Reconnect, backoff and per-topic degradation are **unit-proven only**.
|
||||
|
||||
### 3. Wildcard tags against a live broker
|
||||
The by-filter indexing fix that keeps wildcard tags alive across a reconnect is covered by unit tests;
|
||||
no live test authors a `+`/`#` tag and bounces the connection.
|
||||
|
||||
### 4. QoS 2 delivery semantics
|
||||
The fixture publishes at QoS 0/1. QoS 2's exactly-once handshake is never exercised end-to-end.
|
||||
|
||||
### 5. Scale
|
||||
One publisher, five topics, a ~2 s cadence. Nothing probes the 50 000-node browse cap, the 1 MiB
|
||||
payload cap, or thousands of authored tags.
|
||||
|
||||
### 6. Redundant-pair client-id collision
|
||||
Found by hand during the P1 gate (a fixed `clientId` makes pair nodes evict each other), **not** by a
|
||||
test. No automated coverage asserts that two concurrent drivers coexist.
|
||||
|
||||
## When to trust the MQTT fixture, when to reach for a real broker
|
||||
|
||||
| Question | Fixture answers it? |
|
||||
|---|---|
|
||||
| Does TLS + auth work, and does bad auth fail loudly? | ✅ yes |
|
||||
| Is a retained message seeded on subscribe? | ✅ yes |
|
||||
| Does JSONPath extraction + coercion behave? | ✅ yes (unit + live) |
|
||||
| Does the driver ignore unauthored traffic? | ✅ yes |
|
||||
| Does it survive a broker restart / flapping link? | ❌ unit-only |
|
||||
| Does it hold up at plant tag counts? | ❌ no |
|
||||
| Does a Sparkplug birth → alias → DATA → death cycle work? | ✅ yes (live, vs. the simulator) |
|
||||
| Does an NCMD rebirth request actually get answered? | ✅ yes (the simulator subscribes + re-births) |
|
||||
| Sparkplug seq-gap → rebirth | ❌ unit-only (the sim never skips a seq) |
|
||||
| Sparkplug primary-host STATE / DCMD writes | ❌ not implemented |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. Broker-restart / SUBACK-failure live tests (closes gap 2).
|
||||
2. A live wildcard-across-reconnect test (closes gap 3).
|
||||
3. A concurrent-two-driver test pinning the client-id hazard (closes gap 6).
|
||||
4. A seq-gap injection switch on the simulator, closing the last unit-only Sparkplug leg.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml`
|
||||
- `tests/Drivers/…/Docker/mosquitto.conf` · `Docker/gen-fixture-material.sh` · `Docker/publisher/publish.sh`
|
||||
- `tests/Drivers/…/MqttFixture.cs` · `PlainMqttLiveTests.cs`
|
||||
- [`infra/README.md`](../../infra/README.md) §3 — bring-up, endpoints, CA fetch
|
||||
@@ -0,0 +1,397 @@
|
||||
# MQTT Driver
|
||||
|
||||
In-process MQTT **broker-subscribe** driver. It holds one MQTTnet 5 client against one broker,
|
||||
subscribes to the topics its authored tags name, and forwards each PUBLISH straight to
|
||||
`ISubscribable.OnDataChange`. Ingest is **push, not poll**, and **one-way** — there is no `IWritable`.
|
||||
|
||||
**Two ingest shapes, selected by the driver's `Mode`:** `Plain` binds a tag to a concrete MQTT topic;
|
||||
`SparkplugB` binds it to a `group / edge node / device? / metric` tuple and decodes Eclipse-Tahu
|
||||
protobuf under one `spBv1.0/{GroupId}/#` subscription. Both run over the same single client.
|
||||
|
||||
See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) for the harness and
|
||||
[`../plans/2026-07-15-mqtt-sparkplug-driver-design.md`](../plans/2026-07-15-mqtt-sparkplug-driver-design.md)
|
||||
for the design.
|
||||
|
||||
> **Status.** P1 (plain MQTT) and P2 (Sparkplug B ingest) are both shipped and live-gated against the
|
||||
> Mosquitto + Sparkplug-simulator fixture. Write-through (`IWritable` — NCMD/DCMD/plain publish) is
|
||||
> **deferred** ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)); every MQTT node
|
||||
> materializes read-only. See [Known gaps](#known-gaps) — every entry there carries a tracking issue
|
||||
> (#507–#515).
|
||||
|
||||
## Project Layout
|
||||
|
||||
| Project | Holds |
|
||||
|---|---|
|
||||
| `Driver.Mqtt.Contracts` | Config records, enums, `MqttTagDefinition` + the pure `MqttTagDefinitionFactory`, and the one shared `MqttJson.Options`. **Deliberately transport-free** — no MQTTnet reference |
|
||||
| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, factory registration, `MqttDriverProbe` |
|
||||
| `Driver.Mqtt.Browser` | `MqttDriverBrowser` + `MqttBrowseSession` — the AdminUI address picker's `#`-observation session |
|
||||
|
||||
> `.Browser` references `.Driver` (not just `.Contracts`) — a **documented, deliberate exception**
|
||||
> so browse can reuse `MqttConnection.BuildClientOptions` (TLS / CA-pin / credentials / protocol
|
||||
> mapping). Its cost is pulling `Core` into the AdminUI graph. The csproj carries a boxed warning;
|
||||
> do not copy the pattern into a new driver's browser. The clean fix — extract the pure
|
||||
> options builder down into `.Contracts` — is tracked as
|
||||
> [#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512).
|
||||
|
||||
## Capability Surface
|
||||
|
||||
```csharp
|
||||
public sealed class MqttDriver
|
||||
: IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable, IAsyncDisposable, IDisposable
|
||||
```
|
||||
|
||||
| Capability | Entry point | Notes |
|
||||
|---|---|---|
|
||||
| `IDriver` | `InitializeAsync` / `ReinitializeAsync` / `ShutdownAsync` / `GetHealth` | Order **register → attach → connect** is contractual. `ReinitializeAsync` applies a tag-only delta in place, and rebuilds the session when the broker identity changed |
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Replays **only the authored `rawTags`** into an `Mqtt` folder. `SupportsOnlineDiscovery => false`. `RediscoverPolicy` is `Once` in Plain and **`UntilStable` in Sparkplug** (half of what a Sparkplug tag needs — its datatype — is only knowable from a birth) |
|
||||
| `ISubscribable` | `SubscribeAsync` / `OnDataChange` | One SUBSCRIBE per distinct authored topic. **`publishingInterval` is ignored** — MQTT is push-based; this driver neither polls nor throttles |
|
||||
| `IReadable` | `ReadAsync` | Serves the last received/retained value from `LastValueCache`; never throws. A never-seen tag reads `BadWaitingForInitialData` |
|
||||
| `IHostConnectivityProbe` | `GetHostStatuses` | 1-second poll of `MqttConnection.State`; `HostName` is `"{host}:{port}"` |
|
||||
| `IRediscoverable` | `OnRediscoveryNeeded` | Nothing raises it in Plain mode. **Sparkplug raises it** when a birth for an *authored* scope carries a metric-name set different from the last one seen for that scope — both conditions are anti-storm, see [Rediscovery](#rediscovery-sparkplug) |
|
||||
|
||||
**Not implemented:** `IWritable`, `IPerCallHostResolver`, `IAlarmSource`, `IHistoryProvider`.
|
||||
Materialized nodes are `SecurityClassification.ViewOnly`, `IsAlarm: false`, `IsHistorized: false` —
|
||||
*"MQTT ingest is one-way … ViewOnly rather than an Operate node whose writes would silently vanish."*
|
||||
|
||||
Driver-type string: **`Mqtt`** (`DriverTypeNames.Mqtt`).
|
||||
|
||||
## Configuration
|
||||
|
||||
Bound through `MqttJson.Options` — **case-insensitive**, enums by **name**, unknown members skipped.
|
||||
|
||||
**The whole connection is authored on the DRIVER** (`DriverConfig`), via `MqttDriverForm` in the `/raw`
|
||||
driver-config modal. Unlike Modbus / S7 / OPC UA Client, MQTT does **not** use the v3
|
||||
endpoint→`DeviceConfig` split: an MQTT driver instance holds exactly one broker session, so
|
||||
`MqttDeviceForm` is informational (the same shape as `GalaxyDeviceForm`) and devices under an MQTT
|
||||
driver are pure organisational grouping in the RawPath.
|
||||
|
||||
> **Why not the device.** `DriverDeviceConfigMerger` merges a device's keys up to the top level *only
|
||||
> when the driver has exactly one device*. A device-authored broker connection therefore disappears
|
||||
> from the merged blob the moment a second device is added — a silent outage with nothing to see at
|
||||
> deploy time. Driver-level authoring is correct for 0, 1 or N devices. A pre-form deployment that put
|
||||
> the connection on a sole device still works (merge-up wins over the driver's keys) and
|
||||
> `MqttDeviceForm` flags those keys so the mismatch is visible; move them to the driver.
|
||||
|
||||
`DriverDeviceConfigMerger` still merges driver + device and injects `rawTags`.
|
||||
|
||||
```jsonc
|
||||
// DriverConfig — authored by MqttDriverForm. PascalCase keys, enums as names.
|
||||
{
|
||||
"Host": "10.100.0.35",
|
||||
"Port": 8883,
|
||||
"UseTls": true,
|
||||
"AllowUntrustedServerCertificate": false,
|
||||
"CaCertificatePath": null, // pins the chain; null = OS trust store
|
||||
"Username": "otopcua",
|
||||
"Password": "", // never commit
|
||||
"ProtocolVersion": "V500",
|
||||
"CleanSession": true,
|
||||
"KeepAliveSeconds": 30,
|
||||
"ConnectTimeoutSeconds": 15,
|
||||
"ReconnectMinBackoffSeconds": 1,
|
||||
"ReconnectMaxBackoffSeconds": 30,
|
||||
"MaxPayloadBytes": 1048576,
|
||||
"Mode": "Plain",
|
||||
"Plain": { "TopicPrefix": "", "DefaultQos": 1 }
|
||||
}
|
||||
|
||||
// DeviceConfig — empty; MQTT has no per-device endpoint.
|
||||
{}
|
||||
```
|
||||
|
||||
### `Sparkplug` sub-object (`Mode: "SparkplugB"`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Mode": "SparkplugB",
|
||||
"Sparkplug": {
|
||||
"GroupId": "OtOpcUaSim", // REQUIRED — the driver's whole subscription scope
|
||||
"HostId": "", // Host Application identity; receive-only (see below)
|
||||
"ActAsPrimaryHost": false, // NOT IMPLEMENTED — logs a warning when true
|
||||
"RequestRebirthOnGap": true, // a seq gap asks the edge node to re-announce (NCMD)
|
||||
"BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `GroupId` | `""` | **Required.** The driver subscribes exactly `spBv1.0/{GroupId}/#`. Blank ⇒ it connects, reports `Healthy`, and ingests **nothing**. It is a literal topic segment, so `/`, `+`, `#` are refused by the form |
|
||||
| `HostId` | `""` | Sparkplug Host Application identity. **Receive-only** — the driver observes `STATE` and never publishes one |
|
||||
| `ActAsPrimaryHost` | `false` | **Not implemented.** Setting it logs a startup **warning** rather than being silently inert — being a primary host is a three-part contract (retained `ONLINE`, a matching `OFFLINE` registered as the MQTT Will *at connect time*, an explicit `OFFLINE` on clean shutdown), and shipping the `ONLINE` half without the Will is strictly worse than shipping neither: a crashed node would leave a retained `ONLINE` asserting forever that a dead host is alive |
|
||||
| `RequestRebirthOnGap` | `true` | On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state |
|
||||
| `BirthObservationWindowSeconds` | `15` | Discovery's birth-collection window. Must be ≥ 1 |
|
||||
|
||||
`MqttDriverForm` authors all five in Sparkplug mode. The sub-object is **merged, never replaced** (a
|
||||
key a newer driver adds inside it survives an older AdminUI), and in `Plain` mode it is not touched at
|
||||
all — so flipping a driver to Plain to look at it and back does not lose the group id. The form never
|
||||
writes `RawTags`; the deploy artifact owns it.
|
||||
|
||||
> **This was a live-gate finding.** Through the P2 implementation `MqttDriverForm` still carried its P1
|
||||
> placeholder — switching Mode to `SparkplugB` rendered a "not available yet" notice and **no group-id
|
||||
> field**, so once Sparkplug ingest shipped there was no way to author a Sparkplug driver from the
|
||||
> AdminUI at all. Pinned by `MqttDriverFormModelTests`' Sparkplug cases.
|
||||
|
||||
Every key has a default, so nothing is strictly required by the binder. Notable ones:
|
||||
`port` **8883**, `useTls` **true**, `protocolVersion` **V500**, `cleanSession` **true**,
|
||||
`keepAliveSeconds` **30**, `connectTimeoutSeconds` **15**,
|
||||
`reconnectMinBackoffSeconds` **1** / `reconnectMaxBackoffSeconds` **30**,
|
||||
`maxPayloadBytes` **1 MiB**, `allowUntrustedServerCertificate` **false**.
|
||||
|
||||
> ⚠️ **Leave `clientId` unset on a redundant pair.** Both nodes of a pair run the driver. MQTT
|
||||
> requires client ids to be unique per broker, so a *fixed* `clientId` makes the two nodes evict each
|
||||
> other forever — the broker logs `Client <id> already connected, closing old connection` and both
|
||||
> nodes reconnect every few seconds while still reporting `Healthy`. Unset (the default) lets MQTTnet
|
||||
> generate a unique id per connection, which is correct. This was observed live during the P1 gate.
|
||||
> (The probe and browser already self-uniquify with `-probe-` / `-browse-` suffixes.) `MqttDriverForm`
|
||||
> defaults the field blank and raises this warning inline the moment a value is typed.
|
||||
|
||||
The form validates every knob against the driver's own `[Range]` bounds (port 1–65535, keep-alive /
|
||||
connect-timeout / backoffs ≥ 1 s, max-backoff ≥ min-backoff, `maxPayloadBytes` ≥ 1, QoS 0–2) and
|
||||
additionally **clamps** on serialize, so an operator who ignores the inline error still cannot persist
|
||||
a `connectTimeoutSeconds: 0` driver-brick.
|
||||
|
||||
## Tag Configuration
|
||||
|
||||
A tag binds in **one of two shapes**, and which one is decided by *which keys are present*, not by a
|
||||
`mode` key on the tag. `MqttTagDefinitionFactory` reads the Sparkplug tuple when any Sparkplug
|
||||
descriptor key is set, and the topic otherwise; the AdminUI's "Tag shape" selector infers the same way
|
||||
on reopen. The key names are single-sourced in `MqttTagConfigKeys` so the browse-commit mapper and the
|
||||
factory cannot drift.
|
||||
|
||||
### Plain shape
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `topic` | string | — | **Required.** Absent/blank rejects the tag ⇒ `BadNodeIdUnknown` |
|
||||
| `payloadFormat` | `Json` \| `Raw` \| `Scalar` | `Json` | **Strict** — a typo'd value rejects the tag |
|
||||
| `jsonPath` | string | `"$"` | Absent *or empty* ⇒ document root. `Json` only |
|
||||
| `dataType` | `DriverDataType` | `String` | **Strict.** `Float64`, not `Double` — see below |
|
||||
| `qos` | int 0–2 | driver's `defaultQos` | **Strict** — a malformed value rejects rather than silently weakening delivery |
|
||||
| `retainSeed` | bool | `true` | Seed this tag from the broker's retained message on subscribe |
|
||||
|
||||
There is **no `mode` key** — the AdminUI's "Tag shape" selector is UI-only, inferred from the blob and
|
||||
never serialized.
|
||||
|
||||
**`dataType` uses `DriverDataType`, which has `Float64` and has no `Double`.** Authoring `"Double"` is
|
||||
a strict-enum rejection, not a fallback.
|
||||
|
||||
Payload handling: `Json` selects via JSONPath then coerces (miss ⇒ `BadDecodingError`, coerce fail ⇒
|
||||
`BadTypeMismatch`); `Raw` returns the payload's UTF-8 text verbatim and **ignores `dataType`**;
|
||||
`Scalar` decodes then coerces. The JSONPath subset is `$`, `$.a.b`, `$['a']`, `$.a[0]` — **no
|
||||
filters, slices, wildcards or recursive descent**.
|
||||
|
||||
### Wildcards
|
||||
|
||||
The **runtime accepts** a `+`/`#` topic (it is ambiguous, not unparseable) and the deploy-time
|
||||
`Inspect` pass **warns**. The **AdminUI editor rejects** it outright — the one place the editor is
|
||||
deliberately stricter than the driver, because one Tag holds one value and a wildcard would feed it
|
||||
from many source topics. Wildcard tags are indexed **by filter**, not by concrete topic, so they
|
||||
survive a reconnect; a message fans out to *every* matching tag, and unauthored topics are dropped.
|
||||
|
||||
### Sparkplug shape
|
||||
|
||||
| Key | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `groupId` | string | — | **Required.** Must match the driver's `Sparkplug.GroupId` to ever receive a message |
|
||||
| `edgeNodeId` | string | — | **Required.** |
|
||||
| `deviceId` | string | *absent* | **Omit** for a metric published by the edge node itself (NBIRTH/NDATA). Absent is meaningfully different from `""` |
|
||||
| `metricName` | string | — | **Required.** The tag's stable binding key across rebirths. **May contain `/`** — `Node Control/Rebirth` and `Properties/Serial Number` are canonical Sparkplug names, and the whole name is one value, never split |
|
||||
| `dataType` | `DriverDataType` | *from the birth* | **Optional override.** Absent ⇒ take whatever type the birth certificate declared. The AdminUI's "(from birth certificate)" option removes the key entirely |
|
||||
| `isHistorized` / `historianTagname` | — | — | Server-side historization, unchanged from Plain |
|
||||
|
||||
`payloadFormat`, `jsonPath`, `qos` and `retainSeed` are **Plain-only** and meaningless here: a
|
||||
Sparkplug body is protobuf, and the subscription is one driver-wide `spBv1.0/{GroupId}/#`, not a
|
||||
per-tag filter.
|
||||
|
||||
`groupId` / `edgeNodeId` / `deviceId` are literal MQTT topic segments, so the AdminUI editor refuses
|
||||
`/`, `+` and `#` in them — a decoded incoming id can never contain one, so such a tag could never
|
||||
bind. `metricName` is deliberately **exempt**: it is not a topic segment.
|
||||
|
||||
```jsonc
|
||||
// A device metric.
|
||||
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"FillCount"}
|
||||
|
||||
// A node-level metric — note deviceId is ABSENT, not blank.
|
||||
{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temperature"}
|
||||
```
|
||||
|
||||
## Sparkplug B Ingest
|
||||
|
||||
One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
|
||||
(`SparkplugIngestor` + `SparkplugCodec` / `AliasTable` / `BirthCache` / `SequenceTracker`):
|
||||
|
||||
- **Births are the only self-describing message.** NBIRTH/DBIRTH name every metric and assign its
|
||||
alias; NDATA/DDATA carry an **alias and no name at all**. So the alias table is rebuilt *per birth*
|
||||
and tags bind **by name**, never by alias — aliases are per-birth and may be reused for a different
|
||||
metric after a rebirth.
|
||||
- **NBIRTH is node-scoped, DBIRTH is device-scoped**, and routing a DBIRTH through the node path
|
||||
wipes `bdSeq`. They are handled separately on purpose.
|
||||
- **Sequence tracking**: `seq` is a wrapping 0–255 counter. A detected gap raises a rebirth request
|
||||
when `RequestRebirthOnGap` is on, rather than silently continuing on stale metric state.
|
||||
- **Death → STALE.** An NDEATH whose `bdSeq` matches the live session (the bdSeq tie is what stops a
|
||||
stale death from killing a fresh session) marks every metric under that edge node `Bad` /
|
||||
`BadNoCommunication`. A DDEATH does the same for one device. The next birth restores them.
|
||||
- **Unsupported datatypes are skipped with a warning, never guessed**: `DataSet`, `Template`,
|
||||
`PropertySet`, `PropertySetList`, `Unknown`. `Int8`→`Int16` and `UInt8`→`UInt16` widen; `*Array`
|
||||
variants map to the element type with the array bit set.
|
||||
- **Ingest state is reset at every session/authoring seam** — a reconnect or a `ReinitializeAsync`
|
||||
must not carry a previous session's alias table into a new one.
|
||||
|
||||
### Rediscovery (Sparkplug)
|
||||
|
||||
`OnRediscoveryNeeded` fires only when a birth is **for an authored scope** *and* its metric-name set
|
||||
**differs** from the last one seen for that scope (order-insensitive, ordinal). Both conditions are
|
||||
anti-storm, not optimisations: rediscovery triggers an address-space rebuild, and an edge node
|
||||
re-births freely — on its own schedule, on every reconnect, and once per rebirth NCMD.
|
||||
|
||||
> ⚠️ **Nothing consumes the signal today.** No runtime component subscribes to
|
||||
> `IRediscoverable.OnRediscoveryNeeded`, and `DriverHostActor.HandleDiscoveredNodes` hard-returns —
|
||||
> discovered-node injection is dormant in v3. The served address space comes from the deploy artifact,
|
||||
> so **a DBIRTH introducing a new metric does not change the OPC UA tree**; you must redeploy. The
|
||||
> driver's decision is observable only as an Information log line
|
||||
> (`… requesting rediscovery.`). This is a v3 platform gap, not an MQTT one.
|
||||
|
||||
## Connection + Failure Semantics
|
||||
|
||||
`MqttConnectionState`: `Disconnected → Connected → Reconnecting → Faulted → Disposed`.
|
||||
A broker that is merely down stays **`Reconnecting` indefinitely** — unreachable is never a fault.
|
||||
Backoff is exponential between the two `reconnectBackoff` bounds.
|
||||
|
||||
**MQTTnet 5 does not throw on a rejected CONNACK** — it returns the code and leaves the client
|
||||
disconnected. Left unhandled, a wrong password produced a `Healthy` driver that never received a
|
||||
value. `MqttConnection` therefore raises `MqttConnectRejectedException`:
|
||||
|
||||
- **Unrecoverable ⇒ `Faulted`, supervisor stops** (retrying cannot help): `MalformedPacket`,
|
||||
`ProtocolError`, `UnsupportedProtocolVersion`, `ClientIdentifierNotValid`, `BadUserNameOrPassword`,
|
||||
`NotAuthorized`, `Banned`, `BadAuthenticationMethod`, `TopicNameInvalid`, `PacketTooLarge`,
|
||||
`PayloadFormatInvalid`, `RetainNotSupported`, `QoSNotSupported`, `ServerMoved`.
|
||||
- **Everything else is transient** and keeps retrying — including `UnspecifiedError` and any future
|
||||
code. (`ServerMoved` is permanent, `UseAnotherServer` is temporary — per the MQTT 5 spec.)
|
||||
|
||||
A reconnect that completes without re-subscribing would be a healthy-looking connection that receives
|
||||
nothing forever, so `OnReconnectedAsync` **throws** when zero filters are granted, tearing the session
|
||||
down rather than swallowing it. A SUBACK failure degrades just the affected RawPaths to
|
||||
`BadCommunicationError`.
|
||||
|
||||
## Browse
|
||||
|
||||
`MqttDriverBrowser` opens a **read-only observation session**: it subscribes at QoS 0 to `#` (or
|
||||
`{topicPrefix}/#`) under a `-browse-`-suffixed client id and builds a `/`-segment tree from whatever
|
||||
publishes. **Nothing is ever published.** It is *inherently incomplete* — a topic that stays silent
|
||||
during the window is invisible — so manual entry remains the escape hatch.
|
||||
|
||||
Bounds: 50 000 nodes (then `⚠ Observation truncated`), 1024-char topics / 256-char segments (then
|
||||
`⚠ Some topics were too long`), 512-byte payload inspection. Sessions are reaped after 2 minutes idle.
|
||||
|
||||
**Sparkplug B browse serves a different tree from the same passive window**: observed NBIRTH/DBIRTH
|
||||
certificates are decoded into `Group → EdgeNode → [Device] → Metric` (a birth is the only Sparkplug
|
||||
message that names its metrics). Same node cap, same bounds, same "publishes nothing" guarantee.
|
||||
|
||||
The bespoke browser wins over the universal `DiscoveryDriverBrowser`, which would decline MQTT anyway
|
||||
(`SupportsOnlineDiscovery` is `false`).
|
||||
|
||||
### Browse-commit writes the address the factory reads
|
||||
|
||||
A committed leaf's address is a **descriptor**, not a single reference string, so
|
||||
`RawBrowseCommitMapper` reads it from the `AttributeInfo.AddressFields` the session states — Plain
|
||||
emits `topic`; Sparkplug emits `groupId` / `edgeNodeId` / `deviceId?` / `metricName`, the names
|
||||
`MqttTagConfigKeys` single-sources for both the mapper and `MqttTagDefinitionFactory`.
|
||||
|
||||
**It is never parsed out of the browse node id.** A Sparkplug node id is
|
||||
`{group}/{node}[/{device}]::{metric}` and a metric name legitimately contains `/`
|
||||
(`Node Control/Rebirth`), so splitting the id cannot recover the tuple. Which keys the session emits
|
||||
is also how the mapper learns the shape — the driver *type* reaching it is just `Mqtt`, and the mode
|
||||
lives on the driver config. A leaf whose attribute lookup returned nothing carries no address and is
|
||||
refused **at commit, in words**, rather than committed as a tag that deploys clean and then reports
|
||||
`BadNodeIdUnknown` forever.
|
||||
|
||||
### Request rebirth (Sparkplug only)
|
||||
|
||||
Births are never retained, so a healthy but quiet plant shows an **empty** picker tree until one
|
||||
lands. The `Request rebirth` affordance in the browse modal is the way to fill it on demand: it
|
||||
publishes a Sparkplug rebirth-request NCMD (`Node Control/Rebirth`) to the selected scope — the **one
|
||||
and only** browse action that publishes anything.
|
||||
|
||||
- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
|
||||
node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
|
||||
beneath it and is refused **whole** past 32, publishing nothing.
|
||||
- **Two clicks, never one** — `Request rebirth…` arms a confirm panel naming the resolved scope and
|
||||
its consequence; changing the selection disarms it.
|
||||
- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
|
||||
(fail-closed) before the session is touched; the UI gate is defence in depth. A refusal — policy,
|
||||
over-wide group, unresolvable scope — is **shown**, not swallowed.
|
||||
- Offered only for a Sparkplug session (`IRebirthCapableBrowseSession.RebirthAvailable`); a Plain
|
||||
MQTT window publishes nothing, ever.
|
||||
|
||||
### Refresh
|
||||
|
||||
An observation window **accumulates**, but the tree renders **once**, when it is created — and
|
||||
`OpenAsync` returns as soon as the SUBSCRIBE is granted, without waiting for the birth-observation
|
||||
window. So the first render is normally empty on Plain (a topic that has not published yet is
|
||||
invisible) and *almost always* empty on Sparkplug (births are never retained). **Refresh** re-reads
|
||||
the root against the same session: nothing reconnects, nothing already observed is lost, and the tag
|
||||
selection is kept. Only the armed rebirth scope is cleared, because the node it named may no longer be
|
||||
in the rebuilt tree.
|
||||
|
||||
> **Also fixed by this gate, and not MQTT-specific:** every node label in the shared
|
||||
> `DriverBrowseTree` was an `<a href="#">`. `blazor.web.js`'s enhanced-navigation click interceptor
|
||||
> resolves a bare `#` against `<base href="/">`, so clicking a node **label** navigated the whole
|
||||
> AdminUI to `/` and destroyed the hosting modal (`@onclick:preventDefault` suppresses the browser's
|
||||
> default action, not Blazor's interceptor). The labels are now `<button type="button">`. This
|
||||
> affected **every** driver's picker since 2026-05-28; it surfaced here because choosing a rebirth
|
||||
> scope is the first flow that requires clicking a label rather than the ▶ toggle.
|
||||
|
||||
> ⚠️ **Known gap — a completely empty tree cannot arm a rebirth.** The rebirth scope is taken from the
|
||||
> last node clicked *in the tree*, so on a plant that has not birthed since the window opened there is
|
||||
> nothing to click and `Request rebirth…` stays disabled — the exact case the affordance exists for.
|
||||
> `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare `{group}/{edgeNode}` for a node the
|
||||
> window has never seen (it calls that "the prime rebirth target"); what is missing is a UI path to
|
||||
> enter one, or to default the scope to the driver's own configured `GroupId`. Until then: use
|
||||
> **Refresh** after any birth, or author the tags by **Manual entry**, which is always available.
|
||||
|
||||
## Test Connect
|
||||
|
||||
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
|
||||
disconnects. Success message: `"MQTT CONNECT OK"`. A rejected CONNACK is described by the same
|
||||
`DescribeConnackRejection` the running driver uses, so the button and the driver report a rejection in
|
||||
identical words. See [TestConnectProbes.md](TestConnectProbes.md).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** — `tests/Drivers/…Driver.Mqtt.Tests` (**581 tests**): tag-config mapping (both shapes),
|
||||
JSONPath subset, strict-enum rejection, subscription indexing (incl. the wildcard-by-filter case),
|
||||
reconnect/rebuild branches, CONNACK classification, and the Sparkplug half — golden-payload decode,
|
||||
topic parse/format, datatype map, alias/birth-cache rebuild, sequence + bdSeq-death ties, the
|
||||
rediscovery change gate, and the browse session's "publishes nothing" assertion.
|
||||
- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (**15 tests**) against the
|
||||
Mosquitto broker + the C# Sparkplug edge-node simulator; skips cleanly when `MQTT_FIXTURE_ENDPOINT`
|
||||
is unset. See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3.
|
||||
- **AdminUI** — `MqttDriverFormModelTests` / `MqttTagConfigModelTests` cover both editors'
|
||||
round-trip, mode inference and validation. AdminUI has no bUnit, so the razor shells themselves are
|
||||
only ever proven by a live `/run` gate.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- **Read-only.** No writes reach the broker; every node is ViewOnly. Sparkplug NCMD is used for
|
||||
**rebirth requests only** — never to command a device.
|
||||
- **No alarms, no driver-side history.** Historization is a server-side concern (`isHistorized`).
|
||||
- **Both pair nodes subscribe independently** — MQTT fan-out is per-connection, and the Primary gate
|
||||
applies downstream, not to ingest. This is also why `clientId` must stay unset (above).
|
||||
- **Unauthored traffic is ignored.** A busy broker is normal; the driver never auto-provisions a tag.
|
||||
|
||||
## Known gaps
|
||||
|
||||
Every row is tracked. None is a silent failure — each either refuses in words or logs.
|
||||
|
||||
| Gap | Issue | Effect | Notes |
|
||||
|---|:---:|---|---|
|
||||
| **Write-through (`IWritable`)** | [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508) | Every MQTT node is read-only | Deferred to P3 — NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo |
|
||||
| **Rediscovery is inert** | [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) | A DBIRTH introducing a metric does not change the OPC UA tree; redeploy to pick it up | v3 **platform** gap, not MQTT-specific — nothing subscribes to `OnRediscoveryNeeded` and `DriverHostActor.HandleDiscoveredNodes` hard-returns. Also affects Galaxy / TwinCAT / AbCip. Driver-side decision is log-observable |
|
||||
| **Rebirth needs a non-empty tree** | [#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514) | `Request rebirth…` cannot be armed on a plant that has not birthed since the window opened | See [Refresh](#refresh). Session side already supports a bare `{group}/{edgeNode}` scope |
|
||||
| **`_canRebirth` is captured at browse-open** | [#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510) | A session reaped for idleness (2 min) still draws the button, and using it errors | The error is shown, not swallowed; reopen the browser |
|
||||
| **Primary-host STATE publishing** | [#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511) | `ActAsPrimaryHost` does nothing | Logs a startup warning rather than being silently inert — see the `Sparkplug` sub-object table |
|
||||
| **A metric whose name contains `/` cannot be browse-committed** | [#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509) | The commit is refused **whole**, in words (`Row N: Name must not contain '/'`) | The derived **tag Name** is a RawPath segment and may not contain `/`, while `metricName` legitimately may. This blocks the spec-mandatory `Node Control/Rebirth`. **Workaround: Manual entry** — author the tag with a `/`-free name and type the slashed `metricName` into the Sparkplug editor, which accepts it. Nothing is silently mis-bound |
|
||||
| **`DataSet` / `Template` / `PropertySet` metrics** | [#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515) | Skipped with a warning | v1-unsupported; never coerced into a guessed type |
|
||||
| **The tag editor writes `payloadFormat` into a Sparkplug blob** | [#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513) | Cosmetic only | `payloadFormat` is Plain-only; the factory routes on the Sparkplug keys and ignores it. Noise in the blob, not a mis-binding |
|
||||
| **`.Browser` references `.Driver`, not just `.Contracts`** | [#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512) | A documented layering exception | Taken to reuse `MqttConnection.BuildClientOptions` rather than duplicate TLS/CA-pin logic — see the note at the top of this doc |
|
||||
@@ -124,7 +124,7 @@ ConditionType events (non-base `BaseEventType`) is not verified.
|
||||
- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested
|
||||
but not exchanged against a server that actually enforces each.
|
||||
- LDAP-backed `UserName` (matching this repo's server-side
|
||||
`LdapUserAuthenticator`) requires a live LDAP round-trip; not tested.
|
||||
`LdapOpcUaUserAuthenticator`) requires a live LDAP round-trip; not tested.
|
||||
|
||||
## When to trust OpcUaClient tests, when to reach for a server
|
||||
|
||||
|
||||
+20
-5
@@ -1,6 +1,6 @@
|
||||
# Drivers
|
||||
|
||||
OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `Core.Abstractions` + `Server`) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and namespace kinds (Equipment + SystemPlatform). Drivers plug in through **capability interfaces** defined in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
|
||||
OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `Core.Abstractions` + `Server`) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and the two v3 OPC UA namespaces (Raw + UNS — see `V3NodeIds` / `AddressSpaceRealm`). Drivers plug in through **capability interfaces** defined in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
|
||||
|
||||
- `IDriver` — lifecycle (`InitializeAsync`, `ReinitializeAsync`, `ShutdownAsync`, `GetHealth`)
|
||||
- `IReadable` / `IWritable` — one-shot reads and writes
|
||||
@@ -15,20 +15,28 @@ OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `C
|
||||
|
||||
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`), which wraps it in a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. The `OTOPCUA0001` analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
|
||||
|
||||
Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs`). The registry records each type's allowed namespace kinds (`Equipment` / `SystemPlatform` / `Simulated`), its JSON Schema for `DriverConfig` / `DeviceConfig` / `TagConfig` columns, and its stability tier per [docs/v2/driver-stability.md](../v2/driver-stability.md).
|
||||
Driver **factories** are registered at startup in `DriverFactoryRegistry` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs`) — all 12 in one place, `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`. Type names are the `DriverTypeNames` constants (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`), guarded bidirectionally by `DriverTypeNamesGuardTests`.
|
||||
|
||||
> ⚠️ **`DriverTypeRegistry` / `DriverTypeMetadata` are vestigial.** No production code constructs or reads them — the class is referenced only by its own unit tests. Its `AllowedNamespaceKinds` field was retired along with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`), and the `SystemPlatform` namespace kind no longer exists.
|
||||
>
|
||||
> **Stability tier** likewise does not come from that registry: it is the `DriverTier tier = DriverTier.A` parameter on `DriverFactoryRegistry.cs:46`, and **no factory in `src/Drivers/` passes a tier**, so every shipped driver runs Tier A. The Tier-C-only protections described in [docs/v2/driver-stability.md](../v2/driver-stability.md) — `MemoryRecycle` hard-breach kill (`MemoryRecycle.cs:54`) and `ScheduledRecycleScheduler` (`:43`) — are therefore dormant for every driver.
|
||||
|
||||
## Ground-truth driver list
|
||||
|
||||
| Driver | Project path | Tier | Wire / library | Capabilities | Notable quirk |
|
||||
|--------|--------------|:----:|----------------|--------------|---------------|
|
||||
| [Galaxy](Galaxy.md) | `Driver.Galaxy` (+ `.Browser`, `.Contracts`) | A | gRPC to the external `mxaccessgw` gateway (the gateway owns MXAccess COM + the Galaxy Repository SQL reader) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IRediscoverable, IHostConnectivityProbe | In-process .NET 10 driver — the COM bitness constraint lives in the gateway's x86 net48 worker, not here. PR 7.2 retired the legacy in-process `Galaxy.{Shared, Host, Proxy}` + named-pipe Windows service. Native MxAccess alarms work end-to-end |
|
||||
| [Modbus TCP](Modbus.md) | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
|
||||
| [Modbus TCP / RTU-over-TCP](Modbus.md) | `Driver.Modbus` (+ `.Addressing`, `.Contracts`) | A | NModbus-derived in-house client; `Transport` selects MBAP (TCP) or RTU CRC-16 framing over a socket | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver. `Transport=RtuOverTcp` (`ModbusTransportMode`, default `Tcp`) targets serial→Ethernet gateways via `ModbusRtuOverTcpTransport` + `ModbusRtuFraming` + `ModbusCrc`, with `ModbusTransportDesyncException` carrying distinct CrcMismatch/UnitMismatch reasons; **direct-serial transport is descoped**. Merged `0f38f486` (#495) |
|
||||
| [Siemens S7](S7.md) | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
|
||||
| [AB CIP](AbCip.md) | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
||||
| [AB Legacy](AbLegacy.md) | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
||||
| [TwinCAT](TwinCAT.md) | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
|
||||
| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | `IWritable` is implemented but read-only by design — `WriteAsync` returns `BadNotWritable` for every point. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
|
||||
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
|
||||
| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) |
|
||||
| [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange` — `IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific |
|
||||
| **Sql** (no dedicated page — see [design](../plans/2026-07-15-sql-poll-driver-design.md)) | `Driver.Sql` (+ `.Browser`, `.Contracts`) | A | `Microsoft.Data.SqlClient` behind an `ISqlDialect` seam (`SqlServerDialect` shipped; Postgres/ODBC deferred) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe | Read-only DB-poll driver with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). `SqlTagModel.Query` is deferred to P3 and rejected end-to-end by parser, validator, planner and editor. Merged `4ad54037` |
|
||||
| **Calculation** (see [Raw.md](../Raw.md#the-calculation-driver)) | `Driver.Calculation` | A | none — computes from other tags' RawPaths | IDriver, IDependencyConsumer, ISubscribable, IReadable | **Pseudo-driver**, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time `scriptId`-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). ⚠️ **No typed config form** — `DriverConfigModal` has no `Calculation` case, so `RunTimeout` is currently unauthorable through the AdminUI |
|
||||
| [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the sole historian backend. Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) |
|
||||
|
||||
## Per-driver documentation
|
||||
@@ -40,13 +48,18 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
|
||||
- **FOCAS** has a short getting-started doc because the backend-selection env var + alarm projection opt-in need explaining up front:
|
||||
- [FOCAS.md](FOCAS.md) — deployment, config, capability surface, alarm projection, troubleshooting
|
||||
|
||||
- **Modbus TCP**, **AB CIP**, **AB Legacy**, **Siemens S7**, **TwinCAT**, and **OPC UA Client** each have a per-driver overview page:
|
||||
- **Modbus TCP**, **AB CIP**, **AB Legacy**, **Siemens S7**, **TwinCAT**, **OPC UA Client**, and **MQTT** each have a per-driver overview page:
|
||||
- [Modbus.md](Modbus.md) — in-process Modbus-TCP driver: address formats, polled subscription model, DL205 octal mapping
|
||||
- [AbCip.md](AbCip.md) — AB CIP / EtherNet-IP driver (ControlLogix / CompactLogix / Micro800 / GuardLogix): tag discovery, UDT resolution, alarm source
|
||||
- [AbLegacy.md](AbLegacy.md) — AB Legacy PCCC driver (SLC 500 / MicroLogix / PLC-5): file-based addressing, user-authored tag list
|
||||
- [S7.md](S7.md) — Siemens S7 driver (S7-300/400/1200/1500 + S7-200): getting started, config, data-block addressing, serialized single-connection model
|
||||
- [TwinCAT.md](TwinCAT.md) — Beckhoff TwinCAT (ADS) driver: getting started, native-notification subscription, symbol-tree upload
|
||||
- [OpcUaClient.md](OpcUaClient.md) — OPC UA Client (gateway/aggregation) driver: remote-server session, driver-side HistoryRead forwarding, reconnect behaviour
|
||||
- [Mqtt.md](Mqtt.md) — MQTT broker-subscribe driver: broker/TLS/auth config, topic + JSON-path tag binding, retained-value seeding, `#`-observation browser, CONNACK-rejection semantics, **Sparkplug B** (birth/alias/seq/rebirth state machine, birth-driven browse picker, death→STALE), and the **Known gaps** table
|
||||
|
||||
- **MTConnect** has a short getting-started doc because the hand-rolled-vs-TrakHound divergence, the
|
||||
data-plane precedence rules, and a non-trivial known-limitations list need explaining up front:
|
||||
- [MTConnect.md](MTConnect.md) — Agent config, `FullName`==DataItem `id`, `UNAVAILABLE`→`BadNoCommunication`, CONDITION-as-`String`, browse-via-universal-browser, fixture recipe, deferred write-back
|
||||
|
||||
- **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide:
|
||||
- [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).)
|
||||
@@ -64,12 +77,14 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
||||
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session`
|
||||
- [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (**15 tests**) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a **project-owned C# edge-node simulator** on the `sparkplug` compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so `docker restart otopcua-sparkplug-sim` is how you force a fresh one
|
||||
- [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
|
||||
- [MTConnect](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — Dockerized `mtconnect/agent` + stdlib SHDR adapter (two services, the Agent alone reports everything `UNAVAILABLE`); 12/12 against the real Agent, canned XML unit fixtures cover the bulk (491/491)
|
||||
|
||||
## Related cross-driver docs
|
||||
|
||||
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
|
||||
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, and TwinCAT have no alarm source.
|
||||
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source.
|
||||
- [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
|
||||
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules.
|
||||
- [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy.
|
||||
|
||||
@@ -49,6 +49,7 @@ with a human-readable explanation rather than a false-green TCP-open tick.
|
||||
| **TwinCAT** | `AdsClient.Connect` + `ReadStateAsync`. See [degrade semantics](#twincat-degrade) below. | `"ADS state: {state}"` | Deferred — no ADS target |
|
||||
| **FOCAS** | `cnc_allclibhndl3` via a direct `DllImport("fwlib32")` in the probe. See [degrade semantics](#focas-degrade) below. | `"FOCAS handle OK"` | Deferred — no CNC + FWLIB |
|
||||
| **Galaxy** | gRPC unary call to `GalaxyRepository.TestConnection` on the configured mxaccessgw endpoint. See [auth-rejection rule](#galaxy-auth-rejection) below. | `"gateway gRPC OK"` | `http://10.100.0.48:5120` (mxaccessgw) |
|
||||
| **MQTT** | One MQTT `CONNECT` (no SUBSCRIBE, no publish) under a `-probe-`-suffixed client id, then disconnect. **A rejected CONNACK is a returned result code, not an exception** — MQTTnet 5 does not throw — so the probe inspects `ResultCode` and reuses the driver's own `DescribeConnackRejection`, making the button and the running driver word a rejection identically. | `"MQTT CONNECT OK"` | `10.100.0.35:8883` (Mosquitto TLS+auth fixture) |
|
||||
|
||||
**Historian.Wonderware** had a TCP `Hello`→`HelloAck` handshake probe before Phase 5, but the
|
||||
Wonderware historian backend (and its driver-type / probe) has since been **retired** — the historian
|
||||
@@ -133,6 +134,7 @@ above. (Live verification on `10.100.0.48:5120` with no key returns
|
||||
| S7 | Verified | python-snap7 `10.100.0.35:1102` |
|
||||
| AbCip | Verified | CIP sim `10.100.0.35:44818` |
|
||||
| Galaxy | Verified | mxaccessgw `10.100.0.48:5120`; `Unauthenticated` reply counts as Ok |
|
||||
| MQTT | Verified | Mosquitto `10.100.0.35:8883`; live suite covers TLS+auth Ok, foreign-CA rejection, and wrong-password rejection surfacing rather than reporting healthy |
|
||||
| AbLegacy | Deferred | No PLC5/SLC sim; unit-proven + code path identical to AbCip |
|
||||
| TwinCAT | Deferred | No ADS target; unit-proven + degrade guard tested |
|
||||
| FOCAS | Deferred | No CNC + FWLIB on dev host; degrade guard is the CI-observable path |
|
||||
|
||||
+11
-4
@@ -92,7 +92,7 @@ discovery.
|
||||
| `ISubscribable` | native ADS notifications (default), poll fallback | `UseNativeNotifications=true` registers device notifications so the PLC pushes changes; `false` uses the shared `PollGroupEngine` |
|
||||
| `IHostConnectivityProbe` | per-device probe loop | One `HostConnectivityStatus` per configured device; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
|
||||
| `IPerCallHostResolver` | `ResolveHost` lookup in the tag map | Routes each call to the device of the referenced tag; returns an empty-string sentinel when unresolved |
|
||||
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded` so the address space is rebuilt |
|
||||
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded`. ⚠️ **No consumer wires it today** ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) — the address space does **not** rebuild; a config redeploy is required. Driver-side behaviour is log-observable only |
|
||||
|
||||
### Rediscovery on PLC re-download
|
||||
|
||||
@@ -100,8 +100,15 @@ discovery.
|
||||
`DeviceSymbolVersionInvalid` (1809 / 0x0711) — the documented TwinCAT
|
||||
symbol-version-changed signal, raised when a PLC program is re-downloaded —
|
||||
every symbol and notification handle is invalidated. The driver raises
|
||||
`OnRediscoveryNeeded` with a `TwinCAT` scope hint so Core rebuilds the address
|
||||
space rather than treating it as a transient connection error.
|
||||
`OnRediscoveryNeeded` with a `TwinCAT` scope hint, so that the signal is treated
|
||||
as an address-space change rather than a transient connection error.
|
||||
|
||||
⚠️ **That is the intended design, not present behaviour.** No consumer subscribes
|
||||
to `OnRediscoveryNeeded` anywhere in `src/`, and the scope hint is read by nothing
|
||||
([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
|
||||
[#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)). Today the
|
||||
signal is observable only in the driver log; after a PLC re-download the served
|
||||
address space changes only on a config redeploy.
|
||||
|
||||
### Native notifications
|
||||
|
||||
@@ -148,7 +155,7 @@ fixture is down during normal dev.
|
||||
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
|
||||
|
||||
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
|
||||
arrays, per-element historization. `IRediscoverable` still fires and rebuilds array nodes
|
||||
arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518)
|
||||
on PLC re-download (unchanged semantics from scalar nodes).
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
|
||||
@@ -1,18 +1,116 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-12-historian-tcp-transport.md",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeTaskId": 296, "subject": "Task 0: Create feature branch", "status": "pending"},
|
||||
{"id": 1, "nativeTaskId": 297, "subject": "Task 1: Add TCP/TLS fields to client options", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "nativeTaskId": 298, "subject": "Task 2: Client TCP connect factory + FrameChannel rename", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeTaskId": 299, "subject": "Task 3: Switch client default ctor to TCP", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "nativeTaskId": 300, "subject": "Task 4: Host config binding (Host/Port/TLS)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "nativeTaskId": 301, "subject": "Task 5: Sidecar TcpFrameServer", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 6, "nativeTaskId": 302, "subject": "Task 6: Sidecar Program.cs — TCP bootstrap + env", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeTaskId": 303, "subject": "Task 7: Remove dead pipe code + finalize options shape", "status": "pending", "blockedBy": [3, 4, 6]},
|
||||
{"id": 8, "nativeTaskId": 304, "subject": "Task 8: Deploy scripts — env block + firewall + cert", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "nativeTaskId": 305, "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 10, "nativeTaskId": 306, "subject": "Task 10: Docs — TCP transport", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 11, "nativeTaskId": 307, "subject": "Task 11: Verification (build + test + live)", "status": "pending", "blockedBy": [8, 9, 10]}
|
||||
{
|
||||
"id": 0,
|
||||
"nativeTaskId": 296,
|
||||
"subject": "Task 0: Create feature branch",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"nativeTaskId": 297,
|
||||
"subject": "Task 1: Add TCP/TLS fields to client options",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"nativeTaskId": 298,
|
||||
"subject": "Task 2: Client TCP connect factory + FrameChannel rename",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"nativeTaskId": 299,
|
||||
"subject": "Task 3: Switch client default ctor to TCP",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"nativeTaskId": 300,
|
||||
"subject": "Task 4: Host config binding (Host/Port/TLS)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"nativeTaskId": 301,
|
||||
"subject": "Task 5: Sidecar TcpFrameServer",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"nativeTaskId": 302,
|
||||
"subject": "Task 6: Sidecar Program.cs \u2014 TCP bootstrap + env",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"nativeTaskId": 303,
|
||||
"subject": "Task 7: Remove dead pipe code + finalize options shape",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3,
|
||||
4,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"nativeTaskId": 304,
|
||||
"subject": "Task 8: Deploy scripts \u2014 env block + firewall + cert",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"nativeTaskId": 305,
|
||||
"subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"nativeTaskId": 306,
|
||||
"subject": "Task 10: Docs \u2014 TCP transport",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"nativeTaskId": 307,
|
||||
"subject": "Task 11: Verification (build + test + live)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-12"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "OBSOLETE \u2014 targets the bespoke Wonderware TCP/ArchestrA historian sidecar, retired when HistorianGateway became the sole historian backend. There is no Wonderware backend in the tree. Closed as obsolete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,18 +1,111 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase-0-1.md",
|
||||
"tasks": [
|
||||
{"id": 402, "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", "status": "pending"},
|
||||
{"id": 403, "subject": "SP Task 1: Phase 0 — correct 7 stale code comments", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 404, "subject": "SP Task 2: Phase 0 — fix docs/security.md + benign-residue comments", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 405, "subject": "SP Task 3: Phase 0 — mark shipped .tasks.json completed", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 406, "subject": "SP Task 4: Phase 1 H1a — Phase7Applier rebuilds on Changed*", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 407, "subject": "SP Task 5: Phase 1 H1b — VirtualTagHostActor respawns changed child", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 408, "subject": "SP Task 6: Phase 1 H5a — EquipmentVirtualTagPlan.Historize + composer", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 409, "subject": "SP Task 7: Phase 1 H5b — DeploymentArtifact decodes Historize (byte-parity)", "status": "pending", "blockedBy": [408]},
|
||||
{"id": 410, "subject": "SP Task 8: Phase 1 H5c — VirtualTagHostActor invokes IHistoryWriter", "status": "pending", "blockedBy": [407, 409]},
|
||||
{"id": 411, "subject": "SP Task 9: Phase 1 H5d — thread IHistoryWriter through DriverHostActor + DI", "status": "pending", "blockedBy": [410, 403]},
|
||||
{"id": 412, "subject": "SP Task 10: Phase 1 — docs + follow-up bookkeeping", "status": "pending", "blockedBy": [411]},
|
||||
{"id": 413, "subject": "SP Task 11: Phase 0+1 — full build + test + integration review", "status": "pending", "blockedBy": [403, 404, 405, 406, 407, 408, 409, 410, 411, 412]}
|
||||
{
|
||||
"id": 402,
|
||||
"subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 403,
|
||||
"subject": "SP Task 1: Phase 0 \u2014 correct 7 stale code comments",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
"subject": "SP Task 2: Phase 0 \u2014 fix docs/security.md + benign-residue comments",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 405,
|
||||
"subject": "SP Task 3: Phase 0 \u2014 mark shipped .tasks.json completed",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 406,
|
||||
"subject": "SP Task 4: Phase 1 H1a \u2014 Phase7Applier rebuilds on Changed*",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 407,
|
||||
"subject": "SP Task 5: Phase 1 H1b \u2014 VirtualTagHostActor respawns changed child",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 408,
|
||||
"subject": "SP Task 6: Phase 1 H5a \u2014 EquipmentVirtualTagPlan.Historize + composer",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 409,
|
||||
"subject": "SP Task 7: Phase 1 H5b \u2014 DeploymentArtifact decodes Historize (byte-parity)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
408
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 410,
|
||||
"subject": "SP Task 8: Phase 1 H5c \u2014 VirtualTagHostActor invokes IHistoryWriter",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
407,
|
||||
409
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 411,
|
||||
"subject": "SP Task 9: Phase 1 H5d \u2014 thread IHistoryWriter through DriverHostActor + DI",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
410,
|
||||
403
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 412,
|
||||
"subject": "SP Task 10: Phase 1 \u2014 docs + follow-up bookkeeping",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
411
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 413,
|
||||
"subject": "SP Task 11: Phase 0+1 \u2014 full build + test + integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
403,
|
||||
404,
|
||||
405,
|
||||
406,
|
||||
407,
|
||||
408,
|
||||
409,
|
||||
410,
|
||||
411,
|
||||
412
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,15 +2,72 @@
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md",
|
||||
"branch": "feat/stillpending-phase-2-servicelevel",
|
||||
"tasks": [
|
||||
{"id": 414, "subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster", "status": "pending"},
|
||||
{"id": 415, "subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)", "status": "pending", "blockedBy": [414]},
|
||||
{"id": 416, "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", "status": "pending", "blockedBy": [415]},
|
||||
{"id": 417, "subject": "P2 Task 3: HealthTick — periodic DB Ask/PipeTo + PreStart immediate refresh", "status": "pending", "blockedBy": [416]},
|
||||
{"id": 418, "subject": "P2 Task 4: PeerProbeSupervisor — one peer probe per driver peer", "status": "pending"},
|
||||
{"id": 419, "subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)", "status": "pending", "blockedBy": [417, 418]},
|
||||
{"id": 420, "subject": "P2 Task 6: docs/Redundancy.md — calculator is WIRED", "status": "pending"},
|
||||
{"id": 421, "subject": "P2 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [419, 420]},
|
||||
{"id": 422, "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", "status": "pending", "blockedBy": [421]}
|
||||
{
|
||||
"id": 414,
|
||||
"subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 415,
|
||||
"subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
414
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 416,
|
||||
"subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
415
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 417,
|
||||
"subject": "P2 Task 3: HealthTick \u2014 periodic DB Ask/PipeTo + PreStart immediate refresh",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
416
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 418,
|
||||
"subject": "P2 Task 4: PeerProbeSupervisor \u2014 one peer probe per driver peer",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 419,
|
||||
"subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
417,
|
||||
418
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 420,
|
||||
"subject": "P2 Task 6: docs/Redundancy.md \u2014 calculator is WIRED",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 421,
|
||||
"subject": "P2 Task 7: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
419,
|
||||
420
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 422,
|
||||
"subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
421
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,16 +2,80 @@
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md",
|
||||
"branch": "feat/stillpending-phase-3-opcua-standards",
|
||||
"tasks": [
|
||||
{"id": 423, "subject": "P3 Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping", "status": "pending"},
|
||||
{"id": 424, "subject": "P3 Task 2: H6a — mark native conditions (isNative through the sink)", "status": "pending"},
|
||||
{"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]},
|
||||
{"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"},
|
||||
{"id": 427, "subject": "P3 Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing", "status": "pending", "blockedBy": [424]},
|
||||
{"id": 428, "subject": "P3 Task 6: H6d — DriverHostActor inverse map + native-ack route to driver", "status": "pending", "blockedBy": [426, 427]},
|
||||
{"id": 429, "subject": "P3 Task 7: H6e — wire NativeAlarmAckRouter in host DI", "status": "pending", "blockedBy": [427, 428]},
|
||||
{"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"},
|
||||
{"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]},
|
||||
{"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]}
|
||||
{
|
||||
"id": 423,
|
||||
"subject": "P3 Task 1: H2-bit \u2014 NodePermissions.HistoryUpdate + evaluator mapping",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 424,
|
||||
"subject": "P3 Task 2: H6a \u2014 mark native conditions (isNative through the sink)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 425,
|
||||
"subject": "P3 Task 3: H4 \u2014 wire OnEnableDisable over OPC UA",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
424
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 426,
|
||||
"subject": "P3 Task 4: H6b \u2014 AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 427,
|
||||
"subject": "P3 Task 5: H6c \u2014 NativeAlarmAckRouter seam + native OnAcknowledge routing",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
424
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 428,
|
||||
"subject": "P3 Task 6: H6d \u2014 DriverHostActor inverse map + native-ack route to driver",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
426,
|
||||
427
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 429,
|
||||
"subject": "P3 Task 7: H6e \u2014 wire NativeAlarmAckRouter in host DI",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
427,
|
||||
428
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 430,
|
||||
"subject": "P3 Task 8: Docs \u2014 Enable/Disable + native-ack\u2192AVEVA + HistoryUpdate bit",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 431,
|
||||
"subject": "P3 Task 9: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
423,
|
||||
425,
|
||||
429,
|
||||
430
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 432,
|
||||
"subject": "P3 Task 10: Live /run \u2014 H4 Enable/Disable + H6 native-ack route",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
431
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,14 +2,68 @@
|
||||
"planPath": "docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md",
|
||||
"branch": "feat/stillpending-phase-4-driver-datatypes",
|
||||
"tasks": [
|
||||
{"id": 433, "subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)", "status": "pending"},
|
||||
{"id": 434, "subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)", "status": "pending"},
|
||||
{"id": 435, "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", "status": "pending", "blockedBy": [434]},
|
||||
{"id": 436, "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", "status": "pending"},
|
||||
{"id": 437, "subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)", "status": "pending"},
|
||||
{"id": 438, "subject": "P4 Task 6: Docs + bookkeeping", "status": "pending", "blockedBy": [433, 434, 435, 436, 437]},
|
||||
{"id": 439, "subject": "P4 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [433, 434, 435, 436, 437, 438]},
|
||||
{"id": 440, "subject": "P4 Task 8: Live /run — Modbus Int64 (acceptance gate)", "status": "pending", "blockedBy": [439]}
|
||||
{
|
||||
"id": 433,
|
||||
"subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 434,
|
||||
"subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 435,
|
||||
"subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
434
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 436,
|
||||
"subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 437,
|
||||
"subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 438,
|
||||
"subject": "P4 Task 6: Docs + bookkeeping",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
433,
|
||||
434,
|
||||
435,
|
||||
436,
|
||||
437
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 439,
|
||||
"subject": "P4 Task 7: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
433,
|
||||
434,
|
||||
435,
|
||||
436,
|
||||
437,
|
||||
438
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 440,
|
||||
"subject": "P4 Task 8: Live /run \u2014 Modbus Int64 (acceptance gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
439
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -4,16 +4,61 @@
|
||||
"designCommit": "f90017bc",
|
||||
"baseMaster": "c081917a",
|
||||
"branch": "feat/stillpending-phase-4b-driver-gaps (merged to master 08a65513, deleted)",
|
||||
"executionState": "COMPLETE — shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 — all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).",
|
||||
"nativeTaskIds": {"1": 482, "2": 483, "3a": 484, "3b": 485, "4": 486, "5": 487, "6": 488},
|
||||
"executionState": "COMPLETE \u2014 shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 \u2014 all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).",
|
||||
"nativeTaskIds": {
|
||||
"1": 482,
|
||||
"2": 483,
|
||||
"3a": 484,
|
||||
"3b": 485,
|
||||
"4": 486,
|
||||
"5": 487,
|
||||
"6": 488
|
||||
},
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", "status": "completed", "commit": "8b4675b1", "reviewFixCommit": "a40c77de"},
|
||||
{"id": 2, "subject": "Task 2: Galaxy nested gobject hierarchy", "status": "completed", "commit": "21c7645d", "reviewFixCommit": "bec37848"},
|
||||
{"id": "3a", "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", "status": "completed", "commit": "3fcbc70c"},
|
||||
{"id": "3b", "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", "status": "completed", "commit": "6855be28"},
|
||||
{"id": 4, "subject": "Task 4: Docs + bookkeeping", "status": "completed", "commit": "08a65513"},
|
||||
{"id": 5, "subject": "Task 5: Full build + test + final integration review", "status": "completed", "note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"},
|
||||
{"id": 6, "subject": "Task 6: Live /run verification", "status": "completed", "note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"}
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")",
|
||||
"status": "completed",
|
||||
"commit": "8b4675b1",
|
||||
"reviewFixCommit": "a40c77de"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: Galaxy nested gobject hierarchy",
|
||||
"status": "completed",
|
||||
"commit": "21c7645d",
|
||||
"reviewFixCommit": "bec37848"
|
||||
},
|
||||
{
|
||||
"id": "3a",
|
||||
"subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)",
|
||||
"status": "completed",
|
||||
"commit": "3fcbc70c"
|
||||
},
|
||||
{
|
||||
"id": "3b",
|
||||
"subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)",
|
||||
"status": "completed",
|
||||
"commit": "6855be28"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Docs + bookkeeping",
|
||||
"status": "completed",
|
||||
"commit": "08a65513"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Live /run verification",
|
||||
"status": "completed",
|
||||
"note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"
|
||||
}
|
||||
],
|
||||
"reviewFollowUps": [
|
||||
"FOCAS minor: no upper-bound clamp on a misbehaving cnc_getfigure value (Math.Pow overflow; same latent risk the manual path had)",
|
||||
@@ -21,5 +66,6 @@
|
||||
"FOCAS: live auto-fetch on the real backend needs a cnc_getfigure command added to the managed FocasWireClient wire protocol (WireFocasClient returns empty today)",
|
||||
"Galaxy minor: 3-node chain cycle provably-correct but not directly tested (2-node mutual cycle is tested)"
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -4,28 +4,121 @@
|
||||
"designCommit": "efccd8d1",
|
||||
"baseMaster": "050164b2",
|
||||
"branch": "feat/stillpending-phase-4c-array-support (merged to master 0f92e9e2, deleted)",
|
||||
"executionState": "COMPLETE — shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 — all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) — all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).",
|
||||
"executionState": "COMPLETE \u2014 shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 \u2014 all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) \u2014 all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).",
|
||||
"scope": "Big-bang all 5 drivers (AskUserQuestion) + full AbLegacy array read (AskUserQuestion). 1-D read-surface only; array writes / multi-dim / array historization out of scope (array nodes forced read-only at the applier per review M-1).",
|
||||
"nativeTaskIds": {"1": 495, "2": 496, "3": 497, "4": 498, "5": 499, "6": 500, "7": 501, "8": 502, "9": 503, "10": 504, "11": 505, "12": 506},
|
||||
"nativeTaskIds": {
|
||||
"1": 495,
|
||||
"2": 496,
|
||||
"3": 497,
|
||||
"4": 498,
|
||||
"5": 499,
|
||||
"6": 500,
|
||||
"7": 501,
|
||||
"8": 502,
|
||||
"9": 503,
|
||||
"10": 504,
|
||||
"11": 505,
|
||||
"12": 506
|
||||
},
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Sink contract — EnsureVariable array params", "classification": "high-risk", "status": "completed", "commit": "a7928202", "reviewFixCommit": "3172b7bd"},
|
||||
{"id": 2, "subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier", "classification": "high-risk", "status": "completed", "commit": "71cc4171", "reviewFixCommit": "584e9f2a"},
|
||||
{"id": 3, "subject": "DeploymentArtifact decode byte-parity", "classification": "high-risk", "status": "completed", "commit": "0a747c34", "reviewFixCommit": "eb8a8dc1"},
|
||||
{"id": 4, "subject": "AdminUI driver-agnostic isArray/arrayLength control", "classification": "standard", "status": "completed", "commit": "c2006dfb"},
|
||||
{"id": 5, "subject": "Modbus String/BitInRegister array decode + resolver", "classification": "small", "status": "completed", "commit": "8d3dc321", "reviewFixCommit": "49ac1392"},
|
||||
{"id": 6, "subject": "AbCip libplctag array read + IsArray", "classification": "standard", "status": "completed", "commit": "f4d5a5ee", "reviewFixCommit": "94e8c55b+5f7a2acd"},
|
||||
{"id": 7, "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", "classification": "standard", "status": "completed", "commit": "3e742395"},
|
||||
{"id": 8, "subject": "S7 block-read array + decode loop + IsArray", "classification": "high-risk", "status": "completed", "commit": "a82c22c6", "reviewFixCommit": "3bbe39c1+d30fb77e"},
|
||||
{"id": 9, "subject": "AbLegacy PCCC multi-element array read + IsArray", "classification": "high-risk", "status": "completed", "commit": "95006939", "reviewFixCommit": "ce5d46be+0f92e9e2"},
|
||||
{"id": 10, "subject": "Docs + bookkeeping", "classification": "small", "status": "completed", "commit": "05c7e86f"},
|
||||
{"id": 11, "subject": "Full build + test + final integration review", "classification": "standard", "status": "completed", "note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"},
|
||||
{"id": 12, "subject": "Live /run acceptance + finish branch", "classification": "standard", "status": "completed", "note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"}
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Sink contract \u2014 EnsureVariable array params",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "a7928202",
|
||||
"reviewFixCommit": "3172b7bd"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "71cc4171",
|
||||
"reviewFixCommit": "584e9f2a"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "DeploymentArtifact decode byte-parity",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "0a747c34",
|
||||
"reviewFixCommit": "eb8a8dc1"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "AdminUI driver-agnostic isArray/arrayLength control",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"commit": "c2006dfb"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Modbus String/BitInRegister array decode + resolver",
|
||||
"classification": "small",
|
||||
"status": "completed",
|
||||
"commit": "8d3dc321",
|
||||
"reviewFixCommit": "49ac1392"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "AbCip libplctag array read + IsArray",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"commit": "f4d5a5ee",
|
||||
"reviewFixCommit": "94e8c55b+5f7a2acd"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"commit": "3e742395"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "S7 block-read array + decode loop + IsArray",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "a82c22c6",
|
||||
"reviewFixCommit": "3bbe39c1+d30fb77e"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "AbLegacy PCCC multi-element array read + IsArray",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "95006939",
|
||||
"reviewFixCommit": "ce5d46be+0f92e9e2"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Docs + bookkeeping",
|
||||
"classification": "small",
|
||||
"status": "completed",
|
||||
"commit": "05c7e86f"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Full build + test + final integration review",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Live /run acceptance + finish branch",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"
|
||||
}
|
||||
],
|
||||
"reviewFollowUps": [
|
||||
"Foundation array test asserts in-process variable.Value only — add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)",
|
||||
"Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements — add array element formatting",
|
||||
"Foundation array test asserts in-process variable.Value only \u2014 add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)",
|
||||
"Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements \u2014 add array element formatting",
|
||||
"Array WRITES (inbound client->device), multi-dimensional arrays, array historization remain out of scope (named deferrals)",
|
||||
"Live array read for S7/AbCip/TwinCAT/AbLegacy is fixture-gated/operator-gated (unit-proven only); the 5 AbLegacy libplctag PCCC array-read assumptions need a real SLC/MicroLogix to confirm"
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,27 +1,185 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-26-otopcua-historian-gateway-integration.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", "status": "pending", "blockedBy": [] },
|
||||
{ "id": 1, "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 2, "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 3, "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 4, "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 5, "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 6, "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", "status": "pending", "blockedBy": [1, 3] },
|
||||
{ "id": 7, "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", "status": "pending", "blockedBy": [6] },
|
||||
{ "id": 8, "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 9, "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", "status": "pending", "blockedBy": [6, 8] },
|
||||
{ "id": 10, "subject": "Task 11: ReadEventsAsync alarm-history on the data source", "status": "pending", "blockedBy": [6, 4] },
|
||||
{ "id": 11, "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", "status": "pending", "blockedBy": [9, 5] },
|
||||
{ "id": 12, "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", "status": "pending", "blockedBy": [11] },
|
||||
{ "id": 13, "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", "status": "pending", "blockedBy": [9, 2] },
|
||||
{ "id": 14, "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", "status": "pending", "blockedBy": [13] },
|
||||
{ "id": 15, "subject": "Task 16: FasterLog historization outbox store", "status": "pending", "blockedBy": [9] },
|
||||
{ "id": 16, "subject": "Task 17: ContinuousHistorizationRecorder actor", "status": "pending", "blockedBy": [15, 9] },
|
||||
{ "id": 17, "subject": "Task 18: Wire recorder into DI + hosted lifecycle", "status": "pending", "blockedBy": [16] },
|
||||
{ "id": 18, "subject": "Task 19: Retire Wonderware historian projects", "status": "pending", "blockedBy": [9, 12, 17, 19] },
|
||||
{ "id": 19, "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", "status": "pending", "blockedBy": [9, 10, 12, 17] },
|
||||
{ "id": 20, "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", "status": "pending", "blockedBy": [18] }
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 1: Consume gateway packages + scaffold Gateway driver project",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 9: Reshape ServerHistorianOptions to gateway form",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 11: ReadEventsAsync alarm-history on the data source",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 16: FasterLog historization outbox store",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 17: ContinuousHistorizationRecorder actor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
15,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 18: Wire recorder into DI + hosted lifecycle",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 19: Retire Wonderware historian projects",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
12,
|
||||
17,
|
||||
19
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 20: Env-gated live validation vs wonder-sql-vd03",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
10,
|
||||
12,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
18
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-26"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped as PR #423 and live-validated against the gateway on wonder-sql-vd03. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,14 +2,69 @@
|
||||
"planPath": "docs/plans/2026-07-22-per-cluster-mesh-program.md",
|
||||
"note": "PROGRAM plan: each task = write that phase's detailed plan (writing-plans), execute it (executing-plans), run its exit gate, update the tracking tables. Prereq task 0 is the separate selfform-fallback plan in this repo.",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)", "status": "completed", "note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."},
|
||||
{"id": 1, "subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB", "status": "completed", "note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."},
|
||||
{"id": 2, "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", "status": "in_progress", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Phase 4: cut driver-side ConfigDb — EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 6, "subject": "Phase 6: mesh partition — per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope", "status": "pending", "blockedBy": [0, 4, 5]},
|
||||
{"id": 7, "subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook", "status": "pending", "blockedBy": [6]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)",
|
||||
"status": "completed",
|
||||
"note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB",
|
||||
"status": "completed",
|
||||
"note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Phase 4: cut driver-side ConfigDb \u2014 EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Phase 6: mesh partition \u2014 per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
4,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-22T00:00:00Z"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Program COMPLETE (Phases 0a-7 + bootstrap guard), origin/master d1dac87f. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore",
|
||||
"status": "deferred",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
],
|
||||
@@ -128,5 +128,6 @@
|
||||
"result": "LIVE GATE PASSED \u2014 deploy sealed green w/ 4 DB-less site nodes acking; ServiceLevel 240 w/ central SQL down; restart booted last-known-good from LocalDb pointer; grep proof; alarm_condition_state table live+replicated. Doc: 2026-07-23-mesh-phase4-live-gate.md"
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-23"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Task 9 landed 3a590a0c; live gate PASSED 1281aebf. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> **Program design (architecture / shared contract / build order):**
|
||||
> [`2026-07-15-driver-expansion-program-design.md`](2026-07-15-driver-expansion-program-design.md)
|
||||
>
|
||||
> Last updated: 2026-07-24.
|
||||
> Last updated: 2026-07-27.
|
||||
|
||||
## Legend
|
||||
|
||||
@@ -30,10 +30,10 @@ it reaches 📝.
|
||||
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | S–M | none (retrofits shipped drivers) |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
|
||||
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
|
||||
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | S–M | Dockerized `mtconnect/cppagent` — CI-simulatable |
|
||||
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | M–L | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | ✅ **Done** — merged to master `0f38f486` (#495), 11 tasks, live gate PASSED | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
|
||||
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | ✅ **Done** — merged to master `4ad54037`, 22 tasks, live gate PASSED; follow-ups #496/#497/#498 closed in `28c28667` | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
|
||||
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | ✅ **COMPLETE** — P1 Agent MVP, all 23 tasks + the 5-leg live gate PASSED (branch `feat/mtconnect-driver`, PR #506) | S–M | Dockerized `mtconnect/agent:2.7.0.12` (not `cppagent`) — CI-simulatable, live at `10.100.0.35:5000` |
|
||||
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | ✅ **COMPLETE** — P1 + P2 shipped, both live-gated (Tasks 0–26) | M–L | Mosquitto TLS+auth **+ C# Sparkplug edge-node simulator**, live at `10.100.0.35:8883` |
|
||||
|
||||
> Waves 3+ (BACnet/IP, Omron) and the deferred MELSEC are tracked in the program design doc §6/§8,
|
||||
> not here. Omron CIP is the program's sole real-hardware wire gate; BACnet's broadcast/BBMD leg is
|
||||
@@ -49,22 +49,28 @@ running the classification-driven review chain (`trivial` = implement only … `
|
||||
spec-review → code-review → integration review) between tasks. The `.tasks.json` next to each plan
|
||||
tracks progress and lets a later session resume.
|
||||
|
||||
**Paste one of these into Claude Code to build a driver:**
|
||||
> ⚠️ **All four plans below have been executed and merged.** This table is retained as the *pattern*
|
||||
> for executing a future driver plan — do **not** run these four commands, they would rebuild shipped
|
||||
> drivers in a fresh worktree. (Their `.tasks.json` files still read `pending`; that is stale
|
||||
> bookkeeping, not backlog — see `deferment.md` §6.1.)
|
||||
|
||||
| Deliverable | Command |
|
||||
|---|---|
|
||||
| Modbus RTU (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-modbus-rtu-driver.md in a new git worktree` |
|
||||
| SQL poll (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-sql-poll-driver.md in a new git worktree` |
|
||||
| MTConnect (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mtconnect-driver.md in a new git worktree` |
|
||||
| MQTT/Sparkplug (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md in a new git worktree` |
|
||||
| Deliverable | Status | Command (pattern only — do not re-run) |
|
||||
|---|---|---|
|
||||
| Modbus RTU (Wave 1) | ✅ merged `0f38f486` | ~~`… execute docs/plans/2026-07-24-modbus-rtu-driver.md …`~~ |
|
||||
| SQL poll (Wave 1) | ✅ merged `4ad54037` | ~~`… execute docs/plans/2026-07-24-sql-poll-driver.md …`~~ |
|
||||
| MTConnect (Wave 2) | ✅ merged `90bdaa44` (#506) | ~~`… execute docs/plans/2026-07-24-mtconnect-driver.md …`~~ |
|
||||
| MQTT/Sparkplug (Wave 2) | ✅ merged `c3a2b0f7` | ~~`… execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md …`~~ |
|
||||
|
||||
For a **new** driver the invocation shape is:
|
||||
`Use superpowers-extended-cc:subagent-driven-development to execute <plan-path> in a new git worktree`
|
||||
|
||||
- **Slash-command form** (equivalent): `/superpowers-extended-cc:subagent-driven-development <plan-path>`.
|
||||
- **Parallel-session / resume form** (batch execution with checkpoints, no fresh-subagent-per-task):
|
||||
`/superpowers-extended-cc:executing-plans <plan-path>` — reads the same `.tasks.json` and continues
|
||||
from the first pending task.
|
||||
- **Recommended order:** Modbus RTU → SQL poll → MTConnect → MQTT/Sparkplug (lowest effort first;
|
||||
see each wave below for the gating notes). Run one plan per worktree; the four plans are
|
||||
independent, so separate worktrees may run concurrently.
|
||||
- **Order (historical):** the four were built lowest-effort-first — Modbus RTU → SQL poll → MTConnect
|
||||
→ MQTT/Sparkplug. All are merged; nothing in this list remains to schedule. Run one plan per
|
||||
worktree; independent plans may run concurrently in separate worktrees.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,27 +93,44 @@ marginal cost.
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — low-effort / high-leverage pair 📝
|
||||
## Wave 1 — low-effort / high-leverage pair ✅
|
||||
|
||||
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
|
||||
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
|
||||
SQL poll reuses the always-on central SQL Server. **Both shipped.**
|
||||
|
||||
### Modbus RTU — 📝 Plan ready
|
||||
### Modbus RTU — ✅ Done (merged `0f38f486` / #495, live gate PASSED)
|
||||
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk).
|
||||
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk), all complete via subagent-driven-development.
|
||||
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
|
||||
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
|
||||
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
|
||||
gateway is the only shipped mode, so there is no serial hardware gate.
|
||||
- **Fixture:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1
|
||||
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server.
|
||||
- **Effort:** **S — the lowest on the roadmap.** Recommended first.
|
||||
- **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
|
||||
to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
|
||||
`framer=rtu` on a TCP server (wire-probed a pure RTU FC03 response, no MBAP) — so the simple profile
|
||||
path was taken; **no stdlib fallback server was needed.**
|
||||
- **Verification (all green):** unit suite **344/344**; full solution build 0 errors; the 2 RTU
|
||||
integration tests pass **live** against the real `framer=rtu` fixture (read HR5→5, write+readback
|
||||
HR200←4242). Per-task review chain (spec + code reviewers) + a final whole-branch review, all
|
||||
approved-to-merge.
|
||||
- **Live `/run` gate — PASSED (2026-07-24, docker-dev rebuilt from this branch):**
|
||||
- AdminUI `/raw` Modbus **driver** config modal renders the new **Transport** selector (with the
|
||||
operator hint) — set to `RtuOverTcp`, saved.
|
||||
- **Device Test Connect → `OK · 23 MS`** — the probe built a `ModbusRtuOverTcpTransport` via the
|
||||
factory and spoke **FC03 over real RTU framing** to `10.100.0.35:5021`.
|
||||
- Authored raw tags HR5 (r/o) + HR200 (r/w) → **deployment `06ec1632` Sealed** (all 6 nodes acked).
|
||||
- Client.CLI on `opc.tcp://localhost:4840` (`multi-role`/`password`): **read** `ns=2;s=rtu-gate/Device1/HR5`
|
||||
→ **5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
|
||||
- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
|
||||
|
||||
### SQL poll — 📝 Plan ready
|
||||
### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED)
|
||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks**. `ISqlDialect` seam in from day one; only the SQL Server dialect built in v1 (Postgres/ODBC deferred).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server P1;
|
||||
Postgres/ODBC land in P2/P3 behind `ISqlDialect`).
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam.
|
||||
- **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate
|
||||
(`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a
|
||||
persisted `connectionString` (`1919a8e5`).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server
|
||||
shipped; Postgres/ODBC deferred behind `ISqlDialect`).
|
||||
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
|
||||
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
|
||||
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
|
||||
@@ -116,41 +139,123 @@ SQL poll reuses the always-on central SQL Server. This is the recommended next b
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — strategic telemetry / UNS pair 📝
|
||||
## Wave 2 — strategic telemetry / UNS pair ✅
|
||||
|
||||
Both CI-simulatable on the shared docker host, no hardware.
|
||||
|
||||
### MTConnect Agent — 📝 Plan ready
|
||||
### MTConnect Agent — ✅ Done (P1 Agent MVP)
|
||||
- **Design:** [`2026-07-15-mtconnect-driver-design.md`](2026-07-15-mtconnect-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-mtconnect-driver.md`](2026-07-24-mtconnect-driver.md) · [`.tasks.json`](2026-07-24-mtconnect-driver.md.tasks.json) — **23 tasks**. Task 0 is the TrakHound-vs-hand-rolled client decision; browse-picker live-verify is gated on Wave-0 #468.
|
||||
- **Scope:** P1 Agent MVP (`IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+probe+rediscover),
|
||||
browse **free via the Wave-0 universal browser** (`SupportsOnlineDiscovery=true`, no browser code),
|
||||
typed editor, `UNAVAILABLE→BadNoCommunication` mapping, ring-buffer re-baseline paging.
|
||||
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/cppagent`
|
||||
integration fixture (env-gated). Depends on Wave 0's browse seam being live-verified (#468).
|
||||
- **Effort:** S–M (≈1–1.5 wk with TrakHound, ≈2.5–3 wk hand-rolled).
|
||||
- **Implementation plan:** [`2026-07-24-mtconnect-driver.md`](2026-07-24-mtconnect-driver.md) · [`.tasks.json`](2026-07-24-mtconnect-driver.md.tasks.json) — **23 tasks; 22 built, Task 21 (live `/run` gate) tracked separately in the plan file.**
|
||||
- **Driver guide:** [`docs/drivers/MTConnect.md`](../drivers/MTConnect.md) — config keys, capability
|
||||
surface, data-plane precedence rules, quality-code mapping, and (most load-bearing) the **Known
|
||||
limitations** section.
|
||||
- **Scope shipped:** `IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+`IHostConnectivityProbe`+`IRediscoverable`
|
||||
(deliberately no `IWritable` — read-only Agent surface), browse **free via the Wave-0 universal
|
||||
browser** (`SupportsOnlineDiscovery=true`, no browser project), typed AdminUI tag editor,
|
||||
`UNAVAILABLE→BadNoCommunication` mapping, CONDITION-as-`String`, ring-buffer re-baseline paging
|
||||
(both the sequence-gap and `OUT_OF_RANGE` legs).
|
||||
- **Diverged from the design:** Task 0/6/7 dropped the planned TrakHound `MTConnect.NET-Common`/`-HTTP`
|
||||
dependency entirely — the pinned packages ship no XML formatter and the HTTP stream client has no
|
||||
injectable handler to test behind the driver's seam. Parsing is hand-rolled `System.Xml.Linq`,
|
||||
matching on `LocalName` only (namespace-agnostic, so 1.3–2.x all parse). See the driver guide's
|
||||
"Built vs. planned" section.
|
||||
- **Test results:** MTConnect unit suite 491/491; integration suite 12/12 against a real Agent
|
||||
(`mtconnect/agent:2.7.0.12`, **not** `mtconnect/cppagent` — that Docker Hub repo doesn't exist);
|
||||
skips cleanly (12 Skipped) offline. AdminUI 749/749; full solution 0 build errors.
|
||||
- **Deferred (documented, not built):** write-back via MTConnect Interfaces (design §3.6); P1.5
|
||||
CONDITION→native Part-9 alarms, `TIME_SERIES` array materialization, EVENT→enum vocab (design §9);
|
||||
P2 SHDR adapter ingest (design §9). Also two pre-existing fleet-wide gaps this build surfaced
|
||||
rather than caused: `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer (eight
|
||||
drivers besides Galaxy), and no driver form blocks Save on a required-field validation error.
|
||||
- **Fixture:** canned XML unit fixtures (bulk of coverage) + a dockerized `mtconnect/agent` +
|
||||
stdlib-SHDR-adapter integration fixture (env-gated), deployed at `/opt/otopcua-mtconnect/` on the
|
||||
shared docker host. See the fixture's own
|
||||
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md).
|
||||
- **Effort:** S–M, hand-rolled (the TrakHound estimate in the design doc did not apply once that
|
||||
path was ruled out).
|
||||
|
||||
### MQTT / Sparkplug B — 📝 Plan ready
|
||||
### MQTT / Sparkplug B — ✅ **COMPLETE** (P1 + P2, both live-gated)
|
||||
- **Design:** [`2026-07-15-mqtt-sparkplug-driver-design.md`](2026-07-15-mqtt-sparkplug-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-mqtt-sparkplug-driver.md`](2026-07-24-mqtt-sparkplug-driver.md) · [`.tasks.json`](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) — **27 tasks** (P1 plain MQTT = Tasks 0–14, a complete shippable milestone; P2 Sparkplug B = Tasks 15–26). Task 0 is the mandatory MQTTnet-5/net10 + central-pinning validation spike.
|
||||
- **Scope:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→
|
||||
- **Implementation plan:** [`2026-07-24-mqtt-sparkplug-driver.md`](2026-07-24-mqtt-sparkplug-driver.md) · [`.tasks.json`](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) — **27 tasks, all done** (P1 plain MQTT = Tasks 0–14; P2 Sparkplug B = Tasks 15–26).
|
||||
- **Scope delivered:** P1 plain MQTT (MQTTnet-5 connect/TLS/auth + hand-rolled reconnect, subscribe→
|
||||
`OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest
|
||||
(vendored Tahu proto, birth/alias/seq-gap/rebirth state machine, death→STALE).
|
||||
- **Fixture:** Mosquitto/EMQX broker (TLS + real auth, not anonymous) + a **project-owned C#
|
||||
Sparkplug edge-node simulator** for the rebirth/seq-gap/death test matrix + env-gated live suite
|
||||
(`MQTT_FIXTURE_ENDPOINT`).
|
||||
- **Effort:** M–L. **Top risks:** Sparkplug state-machine correctness; MQTTnet-5/net10 + the repo's
|
||||
fragile central pinning (mitigated by hand-rolling Tahu over one MQTTnet-5 client).
|
||||
(vendored Tahu proto + `Grpc.Tools` codegen, birth/alias/seq-gap/rebirth state machine,
|
||||
death→STALE, birth-driven browse tree, scoped Request-rebirth NCMD, typed tag editor).
|
||||
**Write-through (`IWritable`) is deferred to P3** — every MQTT node materializes read-only.
|
||||
- **Fixture:** Mosquitto TLS+auth broker + JSON publisher sidecar, **live** at `10.100.0.35:8883`
|
||||
(`:1883` plaintext-but-authenticated); stack `/opt/otopcua-mqtt`, compose in
|
||||
`tests/Drivers/….Driver.Mqtt.IntegrationTests/Docker/`. Env-gated live suite
|
||||
(`MQTT_FIXTURE_ENDPOINT`, see `infra/README.md` §3). The **project-owned C# Sparkplug edge-node
|
||||
simulator** (`--profile sparkplug`, group `OtOpcUaSim`, nodes `EdgeA`/`EdgeB`, `EdgeA` device
|
||||
`Filler1`) shipped with P2 and answers rebirth NCMDs.
|
||||
- **P2 status (2026-07-25):** Tasks 15–26 complete. **Offline 1528 passed / 0 failed**
|
||||
(581 driver · 809 AdminUI · 138 Core.Abstractions); **live 15/15** against the broker + simulator.
|
||||
- **P1 live gate found two AdminUI authoring gaps** (the gate's whole point — both invisible to green
|
||||
unit tests, because AdminUI has no bUnit and nothing tied these hand-maintained lists to
|
||||
`DriverTypeNames`):
|
||||
1. **FIXED —** `RawDriverTypeDialog`'s option array never got an MQTT row, so **no operator could
|
||||
create an MQTT driver at all**. Fixed, plus a reflection parity guard
|
||||
(`RawDriverTypeDialogParityTests`) that fails for *any* future `DriverTypeNames` entry
|
||||
missing from the picker.
|
||||
2. **FIXED —** `MqttDriverForm` / `MqttDeviceForm` did not exist, so the broker connection was
|
||||
unauthorable from the AdminUI. Both shipped after the P1 gate.
|
||||
- **P2 live gate found a third one, of the same family — FIXED:** `MqttDriverForm` still carried its
|
||||
P1 Sparkplug **placeholder**. Switching Mode to `SparkplugB` rendered a *"not available yet"* notice
|
||||
and **no Group ID field** — so once Sparkplug ingest shipped there was still **no way to author a
|
||||
Sparkplug driver from the AdminUI**, and `Sparkplug.GroupId` is the driver's entire subscription
|
||||
filter (`spBv1.0/{GroupId}/#`): blank ⇒ connected, `Healthy`, ingesting nothing. Fixed (all five
|
||||
Sparkplug keys, merge-not-replace on save, group-id segment validation) + 8 falsifiable
|
||||
`MqttDriverFormModelTests` cases. **Three gates, three instances of the same defect class: a
|
||||
hand-maintained AdminUI surface left behind by a driver-side feature.**
|
||||
- **P2 live gate — two open UI gaps, documented not fixed** (see `docs/drivers/Mqtt.md` §Known gaps):
|
||||
- **A completely empty browse tree cannot arm a rebirth.** The scope comes from a tree click, so on
|
||||
a plant that has not birthed since the window opened `Request rebirth…` stays disabled — the very
|
||||
case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare
|
||||
`{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to enter one is missing.
|
||||
Mitigation shipped: a **Refresh** button on the browse tree (it re-reads the same session, which
|
||||
previously rendered exactly once at open and never again).
|
||||
- **P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — FIXED:** every node label in
|
||||
the shared `DriverBrowseTree` was an `<a href="#">`. In a Blazor Web App, `blazor.web.js`'s
|
||||
enhanced-navigation click interceptor resolves a bare `#` against `<base href="/">`, so **clicking
|
||||
any browse-tree node label navigated the whole AdminUI to `/`**, tore down the circuit and destroyed
|
||||
the hosting modal — losing the browse session and the tag selection. `@onclick:preventDefault` does
|
||||
not help: it suppresses the browser's default action, not Blazor's own interceptor. The labels are
|
||||
now `<button type="button">`. It dates to the component's introduction (2026-05-28) and affects
|
||||
**every** driver's picker; it only surfaced now because Sparkplug's Request-rebirth is the first
|
||||
feature that requires clicking a *label* rather than the ▶ toggle (always a `<button>`, always fine).
|
||||
- **A metric name containing `/` cannot be browse-committed.** The derived raw **tag Name** is a
|
||||
RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
|
||||
spec-mandatory `Node Control/Rebirth` is refused at commit (`Row N: Name must not contain '/'`).
|
||||
The refusal is loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** is
|
||||
the working path (the Sparkplug tag editor accepts a slashed metric name). Fixing it needs a
|
||||
name-sanitisation policy with a collision answer, so it was recorded rather than guessed at.
|
||||
- `_canRebirth` is captured at browse-open, so a reaped session still draws the button.
|
||||
- The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob — cosmetic; the
|
||||
factory routes on the Sparkplug tuple and ignores it.
|
||||
- **Rediscovery is inert in v3 (platform gap, not MQTT's).** The Sparkplug driver raises
|
||||
`OnRediscoveryNeeded` on a changed birth metric-set, but **nothing subscribes to it** and
|
||||
`DriverHostActor.HandleDiscoveredNodes` hard-returns. A DBIRTH introducing a metric does **not**
|
||||
change the OPC UA tree — redeploy. Driver-side behaviour is log-observable only.
|
||||
- **Redundant-pair hazard (documented, not a code change):** both nodes of a pair run the driver, so a
|
||||
**fixed `clientId` makes them evict each other forever** — the broker logs
|
||||
`already connected, closing old connection` and both nodes reconnect every ~2 s while still
|
||||
reporting `Healthy`. Unset (the default) is correct. `MqttDriverForm` warns inline the moment a
|
||||
value is typed.
|
||||
- **Effort:** M–L, delivered. The MQTTnet-5/net10 + central-pinning risk is **retired** (Task 0's
|
||||
spike held through both phases); Sparkplug state-machine correctness was carried by golden-payload
|
||||
vectors + the live simulator.
|
||||
|
||||
---
|
||||
|
||||
## Next actions
|
||||
|
||||
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for MTConnect/BACnet.
|
||||
2. **Build Wave 1** — Modbus RTU first (lowest effort, no new infra), then SQL poll. Both plans are
|
||||
📝 ready; run the command from the [Executing a plan](#executing-a-plan-git-worktree--subagent-per-task)
|
||||
table (subagent-driven-development in a git worktree).
|
||||
3. **Build Wave 2** (MTConnect, then MQTT/Sparkplug) — both plans 📝 ready. MTConnect's browse leg
|
||||
wants #468 closed first; MQTT's Task 0 pinning spike gates everything after it.
|
||||
1. **Close the Wave-0 live gate** (Gitea #468) — unblocks browse verification for BACnet (MTConnect
|
||||
shipped its own Task-21 live `/run` gate independently — see the plan file).
|
||||
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
|
||||
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
|
||||
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
|
||||
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
|
||||
3. **Wave 2 is done.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). Remaining work on both is optional follow-on — MQTT P3 write-through (`IWritable`: NCMD/DCMD + plain publish) plus its two browse-UI
|
||||
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
|
||||
|
||||
Update this file's Summary table and per-wave status whenever a deliverable changes state.
|
||||
|
||||
@@ -1,17 +1,91 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]},
|
||||
{"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]},
|
||||
{"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]},
|
||||
{"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: AdminUI Transport selector on ModbusDriverForm",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: Live /run verification on docker-dev",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8,
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Merged 0f38f486 (#495); live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,34 +1,315 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md",
|
||||
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
|
||||
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet \u2014 its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []},
|
||||
{"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]},
|
||||
{"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]},
|
||||
{"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]},
|
||||
{"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]},
|
||||
{"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]},
|
||||
{"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]},
|
||||
{"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]},
|
||||
{"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]},
|
||||
{"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]},
|
||||
{"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]},
|
||||
{"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]},
|
||||
{"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]},
|
||||
{"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]},
|
||||
{"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]},
|
||||
{"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]},
|
||||
{"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0 (P1): Dependency-validation spike \u2014 MQTTnet-5 pin + net10 restore/build under central pinning",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
6
|
||||
],
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6 (P1): ISubscribable plain topic subscribe\u2192OnDataChange + retained seed",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
5
|
||||
],
|
||||
"blockedBy": [
|
||||
2,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7 (P1): MqttDriver shell \u2014 IDriver + authored-only ITagDiscovery (Once) + probe interface",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
10
|
||||
],
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
7,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
8
|
||||
],
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)",
|
||||
"status": "completed",
|
||||
"classification": "trivial",
|
||||
"parallelizableWith": [
|
||||
12
|
||||
],
|
||||
"blockedBy": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
11
|
||||
],
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14 (P1): Live /run verify on docker-dev \u2014 P1 MILESTONE COMPLETE",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
9,
|
||||
11,
|
||||
12,
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
17
|
||||
],
|
||||
"blockedBy": [
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
16
|
||||
],
|
||||
"blockedBy": [
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 18 (P2): BirthCache + AliasTable \u2014 bind-by-name, rebuild-per-birth",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [
|
||||
19
|
||||
],
|
||||
"blockedBy": [
|
||||
16,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [
|
||||
18
|
||||
],
|
||||
"blockedBy": [
|
||||
16,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 20 (P2): RebirthRequester NCMD encode",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"subject": "Task 21 (P2): Sparkplug ingest state machine \u2014 the 3.6 matrix",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
18,
|
||||
19,
|
||||
20
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
21
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
24
|
||||
],
|
||||
"blockedBy": [
|
||||
20,
|
||||
21
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
23
|
||||
],
|
||||
"blockedBy": [
|
||||
12,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
21,
|
||||
22,
|
||||
23
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"subject": "Task 26 (P2): Live /run verify Sparkplug \u2014 P2 COMPLETE",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
23,
|
||||
24,
|
||||
25
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "All 27 tasks shipped, both phases live-gated. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -48,6 +48,102 @@ git add docs/plans/2026-07-24-mtconnect-driver.md
|
||||
git commit -m "docs(mtconnect): record TrakHound-vs-hand-rolled client decision (Task 0)"
|
||||
```
|
||||
|
||||
**DECISION (verified 2026-07-24): TrakHound path.** All three checks passed against real nuget.org.
|
||||
(1) `dotnet package search MTConnect.NET-Common --exact-match` and `...-HTTP --exact-match` both list
|
||||
versions `3.2.0` through `6.9.0.2` on `nuget.org`, confirming `6.9.0.2` is the current top-of-list
|
||||
version for both package ids (no version mismatch between the two). (2) A throwaway `net10.0` console
|
||||
project in the scratchpad (outside the repo, so unaffected by this repo's `packageSourceMapping`) ran
|
||||
`dotnet add package MTConnect.NET-Common -v 6.9.0.2` + `...-HTTP -v 6.9.0.2` + `dotnet restore` — both
|
||||
restored cleanly with no source-mapping or compatibility errors; `project.assets.json` resolved
|
||||
`MTConnect.NET-Common`, `MTConnect.NET-HTTP`, and the transitive `MTConnect.NET-TLS` (all `6.9.0.2`)
|
||||
against the `net10.0` target framework, and each package's `lib/` folder contains a `netstandard2.0`
|
||||
build (alongside `net6.0`–`net9.0`, `net461`–`net472`, `net47`, `net48`), so net10.0 resolves via the
|
||||
in-box compatibility mapping. (3) The pinned version carries **no embedded LICENSE file** in the
|
||||
`.nupkg` (`unzip -l` shows no `LICENSE*` entry in either package) — instead both nuspecs declare the
|
||||
modern SPDX license-expression form `<license type="expression">MIT</license>` +
|
||||
`<licenseUrl>https://licenses.nuget.org/MIT</licenseUrl>`, which nuget.org validates against the SPDX
|
||||
list at push time and surfaces on the package page (confirmed live: the nuget.org page for
|
||||
`MTConnect.NET-Common` `6.9.0.2` displays "MIT license" linked to `licenses.nuget.org/MIT`) — a
|
||||
structurally stronger guarantee than a free-text embedded file for this specific version, so the
|
||||
absence of a physical `LICENSE` file is not a fail. Task 1 adds
|
||||
`PackageReference MTConnect.NET-Common 6.9.0.2` + `PackageReference MTConnect.NET-HTTP 6.9.0.2` to
|
||||
`Driver.MTConnect.csproj`; the hand-roll fallback is not needed.
|
||||
|
||||
**CORRECTION (Task 6, commit `bdfefadc`) — the decision above rested on incomplete evidence, and the
|
||||
hand-roll fallback IS in use for parsing.** Task 0 verified the packages *exist, restore, and are MIT*;
|
||||
it never verified they can actually parse an MTConnect document. As pinned, they cannot. Task 6 proved
|
||||
this empirically before writing any parser code: reflection over both referenced assemblies
|
||||
(`MTConnect.NET-Common`, 1297 types; `-HTTP`, 410 types) found **zero `IResponseDocumentFormatter`
|
||||
implementations and zero public static `FromXml`/parse entry points**, and a live invocation of
|
||||
TrakHound's own entry point (`ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)`)
|
||||
against this repo's `Fixtures/probe.xml`, with both DLLs loaded, returned:
|
||||
|
||||
```
|
||||
Success = False
|
||||
ERROR: Document Formatter Not found for "xml"
|
||||
```
|
||||
|
||||
TrakHound 6.x resolves formatters from loaded assemblies at runtime, and the XML formatter ships in a
|
||||
**third package, `MTConnect.NET-XML`**, which Task 0 did not evaluate. Even with it added, TrakHound
|
||||
exposes no socket-free `Parse(string)` entry point of the shape this plan mandates. So `/probe` parsing
|
||||
is a hand-rolled `System.Xml.Linq` walk (~250 LoC, `MTConnectProbeParser`); the driver above the
|
||||
`IMTConnectAgentClient` seam is unaffected, exactly as the decision rule anticipated.
|
||||
|
||||
**The two `PackageReference`s remain and are, so far, dead weight.** Task 6 deliberately did not remove
|
||||
them: the hard remaining problem is Task 7's `multipart/x-mixed-replace` chunk framing for `/sample`,
|
||||
and `MTConnect.Clients.MTConnectHttpClientStream` in `-HTTP` may be usable for framing alone. **Task 7
|
||||
owns the final call:** (a) use `-HTTP` for framing only, (b) add `MTConnect.NET-XML` and reconsider
|
||||
wholesale, or (c) hand-roll the boundary reader and **drop both package references** — in which case the
|
||||
Task-0 rationale comment in `Directory.Packages.props` goes with them. Record the outcome here.
|
||||
|
||||
**OUTCOME (Task 7) — option (c): hand-rolled boundary reader, BOTH package references DROPPED.**
|
||||
`MTConnect.NET-Common`, `MTConnect.NET-HTTP` and the transitive `MTConnect.NET-TLS` are gone from
|
||||
`Directory.Packages.props` and from `Driver.MTConnect.csproj`; the Task-0 rationale comments went with
|
||||
them (each replaced by a comment naming this block). **The driver now depends on nothing but the BCL** —
|
||||
`HttpClient` + `System.Xml.Linq`. Reflection over `MTConnect.NET-HTTP` 6.9.0.2 (net9.0 lib, with
|
||||
`-Common` resolved alongside) settled the one open candidate:
|
||||
|
||||
```
|
||||
TYPE MTConnect.Clients.MTConnectHttpClientStream
|
||||
.ctor(String url, String documentFormat)
|
||||
Void Start(CancellationToken), Void Stop(), Task Run(CancellationToken)
|
||||
event DocumentReceived / ErrorReceived / FormatError / ConnectionError / …
|
||||
```
|
||||
|
||||
Three independent disqualifiers, any one of which is fatal:
|
||||
|
||||
1. **It cannot be exercised without a socket.** Its only constructor takes a *URL* and it dials the
|
||||
connection itself inside `Run` — there is no `HttpMessageHandler`, no `HttpClient`, and no `Stream`
|
||||
injection point. The whole `IMTConnectAgentClient` seam exists so Tasks 6–13 are unit-testable
|
||||
against canned XML with no listener; adopting this type would have forced a live agent (or a real
|
||||
loopback HTTP server) into the unit suite.
|
||||
2. **"Framing alone" was never actually on offer.** The type does not surface raw part payloads — it
|
||||
raises `DocumentReceived` with an already-parsed `IDocument`, resolved through the same
|
||||
`documentFormat` formatter lookup that Task 6 proved is missing. Using it for framing would still
|
||||
have required adding `MTConnect.NET-XML`, i.e. option (b) in disguise.
|
||||
3. **The shape is inverted and the payload is heavy.** An event-driven `Start`/`Run` object has to be
|
||||
adapted back into the `IAsyncEnumerable<MTConnectStreamsResult>` the seam declares, and `-HTTP`
|
||||
vendors an entire embedded web server (`Ceen.*` types) that this driver would never touch.
|
||||
|
||||
The replacement is `MultipartStreamReader`, a ~200-line private nested class in
|
||||
`MTConnectAgentClient.cs` that frames `multipart/x-mixed-replace` off the response stream. Two paths:
|
||||
`Content-length` present (every agent in the wild writes it) → read by length and emit the chunk the
|
||||
instant it completes; absent → scan to the next boundary, which necessarily emits one chunk late and
|
||||
exists as a correctness fallback only. Both paths are covered by tests. Every read is bounded by a
|
||||
heartbeat watchdog (`2 × HeartbeatMs + RequestTimeoutMs`) so a silent agent faults instead of wedging
|
||||
the pump — the R2-01 shape — and the buffer is capped at 32 MiB.
|
||||
|
||||
**Also settled here: the streaming leg gets its own `HttpClient`.** `HttpClient.Timeout` is a
|
||||
per-instance whole-response deadline, so the long poll cannot share the unary client. Raising the one
|
||||
timeout would have un-bounded `/probe` + `/current`, which R2-01 forbids; instead `_streamHttp` runs
|
||||
`Timeout.InfiniteTimeSpan` and is bounded by the request deadline on its *header* phase plus the
|
||||
watchdog on its body.
|
||||
|
||||
**Process lesson:** "the dependency restores" is not "the dependency does the job." A library
|
||||
verification checklist needs one end-to-end call against a real input, not just a restore. Task 7 adds
|
||||
a second: **check the constructor for a test seam before counting a library as usable** — a type that
|
||||
can only be handed a URL cannot participate in a socket-free unit suite, however good its internals.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold the two driver projects + the test project
|
||||
@@ -289,6 +385,57 @@ dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests --filter "Ful
|
||||
git add -A && git commit -m "feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)"
|
||||
```
|
||||
|
||||
### Task 7 review remediation (post-`c46540ae`) — three contract changes Tasks 9/11/13 must build against
|
||||
|
||||
Code review of `c46540ae` approved the framing algorithm but moved the risk onto the **contract**. Three
|
||||
changes here are breaking relative to the sketch above; the rest are hardening.
|
||||
|
||||
1. **`SampleAsync` never ends normally except by cancellation.** The seam documents the stream as
|
||||
yielding "indefinitely, until `ct` is cancelled", but the implementation returned normally on EOF, on
|
||||
the closing `--boundary--`, and — worst — on a response that was not multipart at all, where it
|
||||
yielded one parsed snapshot and finished. A pump written to the documented contract has no reason to
|
||||
handle normal completion, so it would silently drop its subscription or hot-loop reconnecting; the
|
||||
non-multipart leg additionally reported a **configuration error as a healthy finished stream**, which
|
||||
is the #485 quiet-successful-termination shape aimed straight at Task 11. Every non-cancellation end
|
||||
now throws: `MTConnectStreamEndedException` (with `MTConnectStreamEndReason.ConnectionClosed` /
|
||||
`ClosingBoundary` — transient, reconnect) or `MTConnectStreamNotSupportedException` (configuration —
|
||||
reconnecting reproduces it forever), sharing the base `MTConnectStreamException`. The non-multipart
|
||||
body is still parsed first, so an Agent's own `MTConnectError` text still wins, but the document is
|
||||
**not** yielded.
|
||||
2. **`IsSequenceGap` moved to `IMTConnectAgentClient` (static) and its first parameter is now
|
||||
`expectedFrom`.** The sketch's `requestedFrom` name and doc were wrong for every chunk after the
|
||||
first: the correct comparand is the **previous chunk's `NextSequence`**, and comparing against the
|
||||
opening `from` reports a gap on every chunk once the ring buffer rolls — an endless `/current`
|
||||
re-baseline storm against a healthy stream. Rehomed onto the seam so Task 11 need not reference the
|
||||
concrete client. **Task 11 must also treat an `OUT_OF_RANGE` `InvalidDataException` as a re-baseline
|
||||
trigger:** cppagent answers a `from` below `firstSequence` with an `MTConnectError` under HTTP 200,
|
||||
so ring-buffer overflow arrives as a *parse failure*, never as a gap-bearing chunk. `IsSequenceGap`
|
||||
alone does not cover it. Documented on the seam.
|
||||
3. **`MTConnectObservation` gained `IsStructured`** (default `false`), set from `element.HasElements`.
|
||||
A DATA_SET/TABLE observation's `<Entry key=…>` children concatenate through `element.Value` to
|
||||
nonsense (`"12"` for two entries) which the index would otherwise publish as Good, and only the
|
||||
parser can tell that apart from a legitimate space-bearing `Message` — no value-shape heuristic
|
||||
works. Deliberately **false for CONDITION** observations: their value comes from the element *name*,
|
||||
so child content cannot corrupt it. The index maps the flag to a status; the parser does not.
|
||||
|
||||
Hardening in the same pass: disposal mid-enumeration now surfaces as `OperationCanceledException`
|
||||
(via an internal dispose-linked token) rather than a raw `ObjectDisposedException`/`IOException` that
|
||||
Task 9's re-init would otherwise inflict on an enumerating pump; `HeartbeatMs`, `SampleIntervalMs` and
|
||||
`SampleCount` join `RequestTimeoutMs` in construction-time positive-value validation (all three reach
|
||||
the query string, and an Agent answers `heartbeat=0` with an HTTP-200 `MTConnectError` that would name
|
||||
no config key); a `multipart/*` response with no `boundary` parameter fails fast instead of degrading
|
||||
into a watchdog timeout; the no-`Content-length` framing fallback logs a one-shot warning (the client
|
||||
now takes an optional `ILogger`); and the shared element/attribute reading rules moved into
|
||||
`MTConnectXml`, used by both parsers.
|
||||
|
||||
**Coverage gap closed, and worth remembering as a pattern.** Every multipart test served the whole body
|
||||
from one buffer, so **every framing test completed in a single `ReadAsync`** — the split-boundary path,
|
||||
the split-header path and the multi-fill loop were correct by inspection only, on the task whose
|
||||
headline risk is framing. A `ChunkedStream` double returning N bytes per read (N = 1, 3, 7) now drives
|
||||
the fixtures through a split transport. Falsifiability confirms the gap was real: removing the
|
||||
split-boundary overlap, removing the split-header overlap, and collapsing `EnsureAsync`'s fill loop each
|
||||
fail **only** the new chunked tests — every pre-existing multipart test stays green under all three.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `MTConnectObservationIndex` + `UNAVAILABLE → BadNoCommunication`
|
||||
@@ -747,6 +894,176 @@ git commit -m "test(mtconnect): live /run gate on docker-dev — browse/read/sub
|
||||
|
||||
---
|
||||
|
||||
### LIVE-GATE RESULT (2026-07-24, completed 2026-07-27) — ALL 5 LEGS PASSED
|
||||
|
||||
**Rig.** The shared `otopcua-dev` rig was NOT used: three sibling driver worktrees
|
||||
(`modbus-rtu`, `mqtt-sparkplug`, `sql-poll-driver`) share its `otopcua-host:dev` image, and it had
|
||||
been up for hours. Instead an isolated stack was built from this worktree —
|
||||
`otopcua-host:mtconnect`, compose project `otopcua-mtc`, overlay
|
||||
`docker-dev/docker-compose.mtconnect.yml`, MAIN pair only, ports AdminUI **9220** / OPC UA **4860**
|
||||
/ SQL **14340**. Mirrors how the mqtt worktree isolated itself. The `otopcua-dev` rig and every
|
||||
sibling worktree were left untouched.
|
||||
|
||||
**Fixture.** `mtconnect/agent:2.7.0.12` + the SHDR adapter deployed to
|
||||
`/opt/otopcua-mtconnect/` on the shared docker host and brought up healthy; `http://10.100.0.35:5000`
|
||||
serves the seeded `OtFixtureCnc` model with live-moving values. Note it answers in the MTConnect
|
||||
**2.0** namespace while the canned fixtures are 1.3 — the namespace-agnostic `LocalName` parsing
|
||||
(Task 6) is what makes both work, now demonstrated rather than argued.
|
||||
|
||||
| Leg | Result | Evidence |
|
||||
|---|---|---|
|
||||
| Integration suite vs. a REAL Agent | ✅ **12/12** | incl. `Discovery_types_an_upper_snake_numeric_event_as_Int64` (the `PART_COUNT` regression), `Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags` (the blocker fix), `Subscribe_delivers_a_changed_value_from_the_live_sample_stream`, `Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs` |
|
||||
| 1. Driver creatable in `/raw` | ✅ | `MTConnect` present in `RawDriverTypeDialog`; `mtc-1` created. Proves the Host `Register` call is genuinely wired — **the one thing no test guards** (deleting it leaves the guard test green and substitutes a `StubbedDriver` while the deploy still seals green). |
|
||||
| 2. Typed config modal renders | ✅ | "Configure driver · MTConnect" with all four sections (Agent / Polling / Connectivity probe / Reconnect), **not** the "no typed config form" banner. **This is the mutation that survived all 749 AdminUI tests** — the `switch` in `DriverConfigModal.razor` compiles to `BuildRenderTree` and is unreachable by reflection. |
|
||||
| 3. Config persists + round-trips | ✅ | Stored `{"agentUri":"http://10.100.0.35:5000","requestTimeoutMs":5000,…,"probe":{"enabled":true,…},"reconnect":{…}}`; reopening the modal displays the saved URI (Razor binding verified — no bUnit in this repo). |
|
||||
| 4. Browse picker → commit | ✅ | "Connect & browse" dialled the live Agent; tree rendered `Agent` + `OtFixtureCnc` with `Favail` (name ≠ id) beside id-named leaves, proving `browseName = Name ?? Id`. Committed TagConfig is **`{"fullName":"fixture_asset_changed","dataType":"String"}`** — the `fullName`-not-`address` fix, live. Wave-0 #468 did **not** block this. |
|
||||
| 5. Deploy → OPC UA read/subscribe | ✅ **PASSED (2026-07-27, post-rebase)** | see "Leg 5" below |
|
||||
|
||||
---
|
||||
|
||||
#### Leg 5 — PASSED 2026-07-27, on the rebased branch
|
||||
|
||||
Re-run after the rebase onto master (`123ddc3f`), against a **freshly rebuilt** `otopcua-host:mtconnect`
|
||||
image, so this gate exercises the merged code and not the pre-rebase tree.
|
||||
|
||||
**The cookie blocker was sidestepped, not worked around.** Rather than moving hosts or stopping the
|
||||
`otopcua-dev` rig, the deploy went through the **headless deploy API** —
|
||||
`POST /api/deployments` with `X-Api-Key` (`DeployApiEndpoints`, `Security:DeployApiKey`). It is
|
||||
`AllowAnonymous().DisableAntiforgery()` by design ("machine endpoint, not a browser form post"), so
|
||||
the antiforgery cookie collision is structurally irrelevant to it. This is the better recipe for any
|
||||
future isolated-stack gate — it needs no browser at all:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:9220/api/deployments \
|
||||
-H "X-Api-Key: docker-dev-deploy-key" -H "Content-Type: application/json" \
|
||||
-d '{"CreatedBy":"mtconnect-live-gate"}'
|
||||
# {"outcome":"Accepted","deploymentId":"…","revisionHash":"…"} HTTP 202
|
||||
```
|
||||
|
||||
Both deployments sealed `Status = 2` (`DeploymentStatus.Sealed`) with `FailureReason = NULL` — i.e.
|
||||
**both** MAIN nodes acked. No `MaintenanceMode` hatch was needed: the isolated stack's topology is
|
||||
MAIN-only with both central nodes enabled and running.
|
||||
|
||||
**Tag set.** Leg 4's AdminUI-committed tag (`fixture_asset_changed`) is an `AssetChanged` EVENT that
|
||||
never moves, so three live-changing tags were added **directly via SQL** (deliberately bypassing the
|
||||
typed editor — see the finding below) to exercise all three type paths.
|
||||
|
||||
**Results — read (`Client.CLI read`, `opc.tcp://localhost:4860`):**
|
||||
|
||||
| NodeId (`ns=2`, Raw realm) | Value | Status |
|
||||
|---|---|---|
|
||||
| `s=mtc-1/vmc/fixture_partcount` | `48099` (Int64) | `0x00000000` Good |
|
||||
| `s=mtc-1/vmc/fixture_x_pos` | `96.4416` (Float64) | `0x00000000` Good |
|
||||
| `s=mtc-1/vmc/fixture_execution` | `ACTIVE` (String) | `0x00000000` Good |
|
||||
| `s=mtc-1/vmc/fixture_asset_changed` | *(null)* | `0x80000000` — see finding 6 |
|
||||
|
||||
The RawPath is `mtc-1/vmc/<dataItemId>` (driver/device/tag; no folder prefix), confirmed by
|
||||
`browse -r`, which rendered all four as `[Variable]` under the `vmc` device object.
|
||||
|
||||
**Results — subscribe (`Client.CLI subscribe -r` over the whole device, 500 ms):** all four monitored;
|
||||
live updates flowed continuously from the `/sample` long-poll pump through to OPC UA — `fixture_x_pos`
|
||||
tracing its sinusoid (`103.66 → 123.18 → 141.90 → 155.27 → 159.99 → 154.93 → 141.32 → 122.48 → 103.03
|
||||
→ 87.75 → 80.35`), `fixture_partcount` incrementing monotonically (`48128 → 48129 → 48130`), and
|
||||
`fixture_execution` transitioning `READY → INTERRUPTED`. **This closes the last unproven link**: the
|
||||
driver seam was already covered by the live integration suite, and leg 5 adds `DeploymentArtifact` →
|
||||
address-space materialisation → OPC UA publish on top of it.
|
||||
|
||||
**Additional findings from leg 5:**
|
||||
|
||||
5. **A bad `dataType` degrades to exactly one skipped tag, loudly — verified by accident.** The
|
||||
hand-written SQL used `"dataType":"Double"`; the vocabulary is `DriverDataType`, whose member is
|
||||
**`Float64`**. The driver logged, per tag:
|
||||
> `could not map the raw tag mtc-1/vmc/fixture_x_pos to an Agent DataItem; it names no readable id
|
||||
> (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The
|
||||
> tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.`
|
||||
|
||||
…and the other three tags kept streaming. That is the intended fail-isolated behaviour,
|
||||
demonstrated live rather than argued. Correcting the row to `Float64` and redeploying turned the
|
||||
tag Good with **no container restart** — which incidentally shows **MTConnect is not subject to the
|
||||
separately-tracked config-edits-silently-discarded defect** that affects Modbus/FOCAS/OpcUaClient.
|
||||
Note also that the typed AdminUI editor would have prevented the mistake outright (it offers a
|
||||
`DriverDataType` dropdown); the error was an artifact of authoring straight into SQL.
|
||||
|
||||
6. **`fixture_asset_changed` reading `0x80000000` is NOT an MTConnect defect — it is a pre-existing,
|
||||
deliberate fidelity gap on the shared publish path.** The agent does return the tag in `/current`
|
||||
as `<AssetChanged …>UNAVAILABLE</AssetChanged>`, and the driver maps it correctly to
|
||||
`BadNoCommunication` (`0x80310000`, `MTConnectObservationIndex.cs:255`). The node nevertheless
|
||||
reports generic `Bad` (`0x80000000`) — while carrying the agent's exact source timestamp, which
|
||||
proves the publish landed and only the status differs. Cause, verified by reading the source:
|
||||
`DriverInstanceActor.QualityFromStatus` (`DriverInstanceActor.cs:1032-1041`) projects the driver's
|
||||
`uint` onto the 3-state `OpcUaQuality` enum using only the top two severity bits (`statusCode >> 30`),
|
||||
and `OtOpcUaNodeManager.StatusFromQuality` (`OtOpcUaNodeManager.cs:3318-3322`) re-expands `Bad` to
|
||||
`StatusCodes.Bad`. It is intentional — `OpcUaQuality`'s own doc comment says *"Real SDK has
|
||||
finer-grained codes; the engine actors only need this 3-state classification."*
|
||||
|
||||
The consequence appears undocumented and is client-visible: **no driver's Bad/Uncertain sub-code
|
||||
ever reaches an OPC UA client.** Issue #497's 16 corrected constants and the new
|
||||
`StatusCodeParityTests` guard keep the constants internally right, but a client cannot tell
|
||||
`BadNoCommunication` from `BadTypeMismatch` from `BadNotSupported`. (The one survivor is
|
||||
`BadWaitingForInitialData`, written directly at the node at `OtOpcUaNodeManager.cs:1806/1901`,
|
||||
bypassing the projection.) Tracked separately; out of scope for this plan.
|
||||
|
||||
---
|
||||
|
||||
**Historical — why leg 5 was blocked on 2026-07-24 (superseded by the API-key recipe above).**
|
||||
Browser cookies are scoped to a
|
||||
**host, not a port**, so the AdminUI antiforgery cookie issued by the still-running `otopcua-dev`
|
||||
rig on `localhost:9200` is sent to the isolated stack on `localhost:9220`, whose data-protection
|
||||
keys differ:
|
||||
|
||||
```
|
||||
Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException: The antiforgery token could not be decrypted.
|
||||
```
|
||||
|
||||
The `Deploy current configuration` POST is rejected before reaching any MTConnect code — no
|
||||
`Deployment` row is created, and the UI surfaces nothing. Clearing JS-visible cookies does not help
|
||||
(the antiforgery cookie is `HttpOnly`). This is a two-stacks-on-one-host collision, entirely outside
|
||||
the driver.
|
||||
|
||||
The three browser-side remedies originally listed (distinct host / incognito profile / stop the
|
||||
`otopcua-dev` rig) all remain valid, but none was needed — the headless deploy API above avoids the
|
||||
browser entirely and is the recommended recipe.
|
||||
|
||||
**Incidental findings from the rig work:**
|
||||
1. ~~`ClusterNode` has no `MaintenanceMode` column, so CLAUDE.md is ahead of the migration.~~
|
||||
**CORRECTED — this was my error, not a repo inconsistency.** The migration
|
||||
`20260722125506_AddClusterNodeMaintenanceMode` exists on master, along with `AkkaPort`/`GrpcPort`.
|
||||
**This branch is 39 commits behind master** (base `963eec1b`, master `28c28667`) and simply
|
||||
predates them, so the isolated stack's schema lacked the column and I reduced the seeded topology
|
||||
by deleting the SITE-A/SITE-B rows instead. CLAUDE.md is accurate. **DONE 2026-07-27 — rebased**
|
||||
onto master `123ddc3f` (67 commits by then, not 39). Conflicts were all of one shape — this driver
|
||||
and the newly-merged SQL-poll driver each adding their entry to the same list — resolved by keeping
|
||||
**both** in: `ZB.MOM.WW.OtOpcUa.slnx`, `DriverTypeNames.cs`, `DriverFactoryBootstrap.cs`,
|
||||
`Core.Abstractions.Tests.csproj`, `TagConfigEditorMap.cs`, `TagConfigValidator.cs`,
|
||||
`DriverConfigModal.razor`, `RawDriverTypeDialog.razor`. Post-rebase: solution build 0 errors,
|
||||
MTConnect 491/491, Core.Abstractions 256/256, AdminUI 800/800. For a future partial-topology gate,
|
||||
`MaintenanceMode = 1` is now available and is the correct hatch (it is what the SQL-poll driver's
|
||||
gate used), in place of the row deletion I resorted to.
|
||||
2. `sqlcmd` against this schema needs `-I` (`SET QUOTED_IDENTIFIER ON`); without it every DML
|
||||
statement fails with `Msg 1934` because of the filtered/computed indexes.
|
||||
3. **Status-code parity is pre-verified against master's new guard.** Master gained
|
||||
`StatusCodeParityTests`, which reflects over every `ZB.MOM.WW.OtOpcUa.Driver.*.dll` in its bin for
|
||||
status-shaped `const uint` fields — including `private const` on `internal` types — and checks each
|
||||
against `Opc.Ua.StatusCodes`. Task 16 added the `Driver.MTConnect` ProjectReference to that same
|
||||
test project, so this driver's constants are in scope. All eight were verified against the pinned
|
||||
SDK assembly directly: `BadCommunicationError 0x80050000`, `BadNoCommunication 0x80310000`,
|
||||
`BadNodeIdUnknown 0x80340000`, `BadNotConnected 0x808A0000`, `BadNotSupported 0x803D0000`,
|
||||
`BadOutOfRange 0x803C0000`, `BadTypeMismatch 0x80740000`, `BadWaitingForInitialData 0x80320000`
|
||||
— **0 mismatches**. Note the guard only sees *named* constants; an inline status literal at a call
|
||||
site is invisible to it, so keep hoisting them. **Confirmed after the rebase**: the guard reports
|
||||
9 MTConnect constants in scope (the 8 above plus `Good`), all passing — the pre-verification held.
|
||||
4. **The separately-tracked `BadTypeMismatch` defect in FOCAS/TwinCAT/AbLegacy/AbCip is already fixed
|
||||
on master** (issue #497 grew it to 16 wrong constants across 6 drivers). This driver independently
|
||||
chose the correct `0x80740000` and pinned it with a mutation test, so the two agree; no action.
|
||||
|
||||
**Teardown when done:**
|
||||
```bash
|
||||
docker compose -p otopcua-mtc -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect.yml down -v
|
||||
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-mtconnect && docker compose down'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 22: Docs + deferred-writeback note; update the tracking doc
|
||||
|
||||
**Classification:** trivial
|
||||
|
||||
@@ -1,29 +1,199 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mtconnect-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]},
|
||||
{"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]},
|
||||
{"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]},
|
||||
{"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]},
|
||||
{"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]},
|
||||
{"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]},
|
||||
{"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]},
|
||||
{"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]},
|
||||
{"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]},
|
||||
{"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]},
|
||||
{"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Scaffold the two driver projects + the test project",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Capture the canned XML fixtures",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: IMTConnectAgentClient seam + return DTOs",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: MTConnectAgentClient \u2014 parse /probe into the device model",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: MTConnectAgentClient \u2014 parse /current + /sample, detect the sequence gap",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: MTConnectDriver shell \u2014 IDriver lifecycle",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: IReadable.ReadAsync \u2014 /current, ordered snapshots",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: ISubscribable \u2014 /sample long-poll pump + ring-buffer re-baseline",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14: MTConnectDriverProbe : IDriverProbe",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15: MTConnectDriverFactoryExtensions",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
14,
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 17: AdminUI typed model MTConnectTagConfigModel",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3,
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 18: AdminUI editor razor + map/validator registration",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 19: mtconnect/cppagent docker fixture",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 20: Env-gated integration suite against cppagent",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
19
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"subject": "Task 21: Live /run verify on docker-dev \u2014 browse picker, editor, read, subscribe, deploy",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
18,
|
||||
20
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"subject": "Task 22: Docs + deferred-writeback note; update the tracking doc",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
21
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Merged at master 90bdaa44 (#506); 5-leg live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,28 +1,191 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-sql-poll-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]},
|
||||
{"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]},
|
||||
{"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]},
|
||||
{"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]},
|
||||
{"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]},
|
||||
{"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]},
|
||||
{"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: SqlPollReader core \u2014 grouped read, bounded deadline, ordered slice-back",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: SqlDriver shell \u2014 IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: Host registration \u2014 DriverTypeNames.Sql + factory + probe + guard test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13: SqlBrowseSession \u2014 schema walk over dialect catalog",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
12,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14: SqlDriverBrowser \u2014 env-ref/literal transient-connection open",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 17: Injection regression test (bind value / reject identifier)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 20: SqlTagConfigEditor.razor shell",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
19
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
11,
|
||||
15,
|
||||
20
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Merged 4ad54037 + follow-ups 28c28667. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
+115
-28
@@ -21,7 +21,7 @@ OtOpcUa has four independent security concerns. This document covers all four:
|
||||
|
||||
1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust).
|
||||
2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind.
|
||||
3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `TriePermissionEvaluator` over a `PermissionTrie` built from the Config DB `NodeAcl` tree.
|
||||
3. **Data-plane authorization** — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by **two coarse, server-wide LDAP role checks** (`WriteOperate`, `AlarmAck`) — **not** by the `NodeAcl` / `PermissionTrie` subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry **no** authorization check at all. See [Data-Plane Authorization](#data-plane-authorization).
|
||||
4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`.
|
||||
|
||||
Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB.
|
||||
@@ -112,9 +112,9 @@ The Server accepts three OPC UA identity-token types:
|
||||
|
||||
| Token | Handler | Notes |
|
||||
|---|---|---|
|
||||
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | Data-plane authorization (below) still default-denies any node a session has no ACL grant for. |
|
||||
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. |
|
||||
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow); finer-grain authorization happens through the data-plane ACLs. |
|
||||
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. |
|
||||
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. |
|
||||
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. |
|
||||
|
||||
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`.
|
||||
|
||||
@@ -130,7 +130,7 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s
|
||||
4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships.
|
||||
5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password".
|
||||
|
||||
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity for the ACL evaluator. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
|
||||
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity (`RoleCarryingUserIdentity`), where the write and alarm-ack gates read them. **The LDAP group names themselves are consumed here and go no further** — which is one reason the `NodeAcl` subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
|
||||
|
||||
`Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production):
|
||||
|
||||
@@ -180,11 +180,92 @@ Defaults are behaviour-neutral on a healthy directory. All new paths are **fail-
|
||||
|
||||
## Data-Plane Authorization
|
||||
|
||||
Data-plane authorization is the check run on every OPC UA operation against an OtOpcUa endpoint: *can this authenticated user Browse / Read / Subscribe / Write / HistoryRead / AckAlarm / Call on this specific node?*
|
||||
> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the
|
||||
> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The
|
||||
> evaluator is built and unit-tested but has **zero production call sites**, and
|
||||
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything
|
||||
> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is
|
||||
> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie).
|
||||
|
||||
Per decision #129 the model is **additive-only — no explicit Deny**. Grants at each hierarchy level union; absence of a grant is the default-deny.
|
||||
### What is enforced today
|
||||
|
||||
### Hierarchy
|
||||
There are exactly **three** authorization checks in the whole OPC UA server layer. All three are
|
||||
coarse, **server-wide role string checks** with no per-node component.
|
||||
|
||||
| Operation | Gate | Requires |
|
||||
|---|---|---|
|
||||
| Write (Value attribute, either namespace) | `OtOpcUaNodeManager.EvaluateEquipmentWriteGate` | role `WriteOperate` |
|
||||
| Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) | `OtOpcUaNodeManager.HandleAlarmCommand` | role `AlarmAck` |
|
||||
| Alarm acknowledge (driver-fed native alarms) | `OtOpcUaNodeManager.HandleNativeAlarmAck` | role `AlarmAck` |
|
||||
|
||||
Both gates fail closed — a session with no identity or no matching role gets
|
||||
`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are
|
||||
the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see
|
||||
[Role grant source](#role-grant-source-data-plane)).
|
||||
|
||||
**What has no authorization check at all:**
|
||||
|
||||
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
|
||||
an Anonymous one, sees and reads the entire address space.
|
||||
- **HistoryRead** — all four overrides (`HistoryReadRawModified`, `HistoryReadProcessed`,
|
||||
`HistoryReadAtTime`, `HistoryReadEvents`) run without an identity check.
|
||||
- **CreateMonitoredItems / subscriptions / TransferSubscriptions**.
|
||||
- **Non-Value attribute writes** — the gate hangs off `OnWriteValue`.
|
||||
|
||||
**And what the write gate does *not* distinguish:**
|
||||
|
||||
- **`WriteTune` and `WriteConfigure` are never checked.** They exist in the role vocabulary and in
|
||||
`NodePermissions`, but every write — whatever the tag's security classification — is gated on the
|
||||
single `WriteOperate` string.
|
||||
- **`AlarmAcknowledge` / `AlarmConfirm` / `AlarmShelve` are not distinguished.** One `AlarmAck` role
|
||||
covers all five alarm methods; the `operation` argument is passed to the router but never consulted
|
||||
by the gate.
|
||||
- **There is no per-node granularity.** A session that may write one tag may write **every** writable
|
||||
tag on the node.
|
||||
|
||||
The gate *is* realm-aware in one narrow sense: it is attached per-node during materialization for both
|
||||
the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself
|
||||
takes no realm and no node.
|
||||
|
||||
### Designed but not wired: `NodeAcl` + `PermissionTrie`
|
||||
|
||||
Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that
|
||||
**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design
|
||||
is sound and the remaining work is wiring plus one genuine design gap — not because it runs.
|
||||
|
||||
What is real about it:
|
||||
|
||||
- Operators **can** author `NodeAcl` rows in the Admin UI (`/clusters/{id}/acls`), and those rows are
|
||||
validated, versioned and persisted.
|
||||
- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL
|
||||
edit shifts the artifact's `RevisionHash` and ships to every node.
|
||||
- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive
|
||||
and are dropped.
|
||||
|
||||
So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour
|
||||
to expect until the tracking issue below is closed.
|
||||
|
||||
**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"):
|
||||
|
||||
1. **Raw-realm nodes have no representable scope.** `NodeHierarchyKind` has exactly one member,
|
||||
`Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot
|
||||
express — yet the write gate is attached to raw tags. Roughly half the writable address space has
|
||||
no scope to evaluate. This is a design decision, not wiring.
|
||||
2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the
|
||||
session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list
|
||||
is discarded inside `LdapOpcUaUserAuthenticator`.
|
||||
3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster
|
||||
grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have
|
||||
to be derived from the artifact's UNS relations.
|
||||
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
|
||||
nothing in production constructs one, stamps a generation, or drives a refresh.
|
||||
|
||||
Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in
|
||||
the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead`
|
||||
has no `OpcUaOperation` mapping, so it is grantable but unreachable.
|
||||
|
||||
### Hierarchy (design)
|
||||
|
||||
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
|
||||
|
||||
@@ -245,19 +326,23 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces
|
||||
|
||||
`NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute.
|
||||
|
||||
### Dispatch gate — `IPermissionEvaluator`
|
||||
### Dispatch gate — `IPermissionEvaluator` (design; **no such gate exists**)
|
||||
|
||||
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call; a `NotGranted` decision denies the operation.
|
||||
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`.
|
||||
|
||||
Key properties:
|
||||
⚠️ **The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built.** `Authorize` has zero production call sites. What the dispatch path really does is in [What is enforced today](#what-is-enforced-today).
|
||||
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator.
|
||||
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. There is no `StrictMode` / fail-open mode; absence of a grant is always a deny.
|
||||
- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit.
|
||||
Intended properties, of which only the last is true today:
|
||||
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else was to flow through the evaluator.
|
||||
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.**
|
||||
- **Evaluator stays pure.** ✅ True — `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired.
|
||||
|
||||
### Full model
|
||||
|
||||
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny).
|
||||
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a **design document for unshipped work**, not as a description of running behaviour.
|
||||
|
||||
Note also that the `SystemPlatform` hierarchy kind named above was retired with the v3 dual-namespace address space; `NodeHierarchyKind` now has only `Equipment`.
|
||||
|
||||
### Role grant source (data-plane)
|
||||
|
||||
@@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.:
|
||||
```json
|
||||
"GroupToRole": {
|
||||
"ot-operators": "WriteOperate",
|
||||
"ot-tuners": "WriteTune",
|
||||
"ot-engineers": "WriteConfigure",
|
||||
"ot-alarm-ack": "AlarmAck",
|
||||
"ot-readonly": "ReadOnly"
|
||||
"ot-alarm-ack": "AlarmAck"
|
||||
}
|
||||
```
|
||||
|
||||
If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes
|
||||
and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who
|
||||
authenticate successfully. (The same requirement gates both the scripted-alarm and the native
|
||||
Galaxy-alarm Part-9 ack/confirm/shelve paths.)
|
||||
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
|
||||
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
|
||||
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
|
||||
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for
|
||||
everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was
|
||||
wrong for three of them.
|
||||
|
||||
The role strings above are **exact, case-insensitive, and code-true** — the inbound gates compare
|
||||
against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings
|
||||
`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not**
|
||||
`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a
|
||||
`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
|
||||
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
|
||||
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
|
||||
the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses,
|
||||
subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry.
|
||||
|
||||
The two live role strings are exact and case-insensitive. In particular the alarm-ack role is
|
||||
`AlarmAck`, **not** `AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different
|
||||
vocabulary); a `GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Admin UI rebuild plan (F15)
|
||||
|
||||
> ⚠️ **Historical (audited 2026-07-27) — describes the v2 AdminUI.** Several named types
|
||||
> (`AuthorizationPolicies`, `IdentificationFields`) no longer exist, and the v3 rebuild replaced the
|
||||
> per-cluster UNS/Equipment/Tags tabs with the global `/uns` page and the `/raw` project tree. Kept
|
||||
> for the record.
|
||||
|
||||
**Status:** UX kickoff — proposals to react to before any per-page rebuild starts.
|
||||
**Last updated:** 2026-05-26 on `v2-akka-fuse`.
|
||||
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ Message contracts are defined; actual SDK calls are stubbed (counters only). Rea
|
||||
|
||||
Both have message contracts wired. Engine integration deferred:
|
||||
|
||||
- `HistorianAdapterActor` — named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink` (F11).
|
||||
- `HistorianAdapterActor` — gRPC to the `ZB.MOM.WW.HistorianGateway` sidecar (the sole historian backend; the named-pipe Wonderware sidecar is retired) + `LocalDbStoreAndForwardSink` (F11; renamed from `SqliteStoreAndForwardSink` when the buffer moved into the consolidated LocalDb).
|
||||
- `PeerOpcUaProbeActor` — real `opc.tcp://peer:4840` ping (F12). Current stub always returns `Ok=true`.
|
||||
|
||||
## DbHealthProbeActor
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# OPC UA Client Authorization (ACL Design) — OtOpcUa v2
|
||||
|
||||
> ⚠️ **NEVER WIRED (verified against source 2026-07-27).** This is a design document for work that was
|
||||
> only half-built. The trie, the builder, the cache and the evaluator all exist in
|
||||
> `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and carry 30 passing unit tests — but
|
||||
> `IPermissionEvaluator.Authorize` has **zero production call sites**, nothing registers it in DI, and
|
||||
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference the project it lives in. `NodeAcl` rows are
|
||||
> authorable in the Admin UI and are snapshotted into every deployment artifact, but the node side never
|
||||
> deserializes them.
|
||||
>
|
||||
> **Nothing described below is enforced.** For what actually gates OPC UA operations today, see
|
||||
> [`docs/security.md`](../security.md) § Data-Plane Authorization and
|
||||
> [`docs/ReadWriteOperations.md`](../ReadWriteOperations.md). Four blockers stand between this design
|
||||
> and a wire-up (Raw-realm nodes have no `NodeScope` representation; the session carries roles, not LDAP
|
||||
> groups; `PermissionTrieBuilder`'s `scopePaths` has no producer; no session lifecycle exists) — they
|
||||
> are recorded in `deferment.md` §3.1.
|
||||
>
|
||||
> Note also that the `SystemPlatform` hierarchy kind used throughout was retired with the v3
|
||||
> dual-namespace address space.
|
||||
>
|
||||
> **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).
|
||||
>
|
||||
> **Branch**: `v2`
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Driver Stability & Isolation — OtOpcUa v2
|
||||
|
||||
> ⚠️ **The tier assignments below are NOT what runs (verified against source 2026-07-27).**
|
||||
> **Every one of the 12 drivers runs as Tier A.** `DriverFactoryRegistry` defaults `DriverTier tier =
|
||||
> DriverTier.A` and **no factory in `src/Drivers/` passes a tier**, so nothing ever selects B or C. The
|
||||
> Galaxy and FOCAS Tier-C assignments in the table below are aspirational.
|
||||
>
|
||||
> Consequences worth knowing:
|
||||
>
|
||||
> - The **Tier-C-only protections are dormant for every driver** — `MemoryRecycle` gates on
|
||||
> `when _tier == DriverTier.C`, and `ScheduledRecycleScheduler` refuses unless Tier C. Neither has ever
|
||||
> engaged in production. `IDriverSupervisor` has zero implementations, yet the config parser still
|
||||
> validates `RecycleIntervalSeconds`, so an operator can author a recycle interval that does nothing.
|
||||
> - `DriverTypeRegistry` — which this document's model implies is the tier authority — is **vestigial**:
|
||||
> referenced only by its own test, with nothing registering metadata at startup.
|
||||
> - The **separate-Windows-service hosting** described for Tier C does not exist either. Galaxy reaches
|
||||
> MXAccess over gRPC to the external **mxaccessgw** sidecar; the `Galaxy.Proxy`/`Host`/`Shared` pattern
|
||||
> named below was retired in PR 7.2, and FOCAS runs in-process like everything else.
|
||||
>
|
||||
> `docs/drivers/README.md` says Tier A for Galaxy and FOCAS and is the accurate one. Keep-or-delete of the
|
||||
> tier machinery is tracked as Gitea **#522**; this document is retained as the design record.
|
||||
>
|
||||
> **Status**: DRAFT — companion to `plan.md`. Defines the stability tier model, per-driver hosting decisions, cross-cutting protections every driver process must apply, and the canonical worked example (FOCAS) for the high-risk tier.
|
||||
>
|
||||
> **Branch**: `v2`
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Phase 7 Status — Scripting Runtime, Virtual Tags, Scripted Alarms, Historian Sink
|
||||
|
||||
> ⚠️ **Historical (audited 2026-07-27) — describes the v2 codebase; do not treat its present-tense
|
||||
> claims as current.** Many named types no longer exist. Notably `:84` reports four services
|
||||
> ("Done | All four exist in `Admin/Services/`") — **none exist and there is no `Admin/Services/`
|
||||
> directory**; the `Phase7*` composition types were renamed to `AddressSpace*` by `40e8a23e`; and
|
||||
> Gaps 2/3/4 (no `/virtual-tags` page, no `/scripted-alarms` page, no script-log viewer) were closed
|
||||
> by v3's `/uns`, `/alerts` and `/script-log` surfaces. Kept for the record.
|
||||
|
||||
> **Reconciliation date**: 2026-05-18
|
||||
> **Based on**: `docs/v2/implementation/phase-7-scripting-and-alarming.md` (the plan) and
|
||||
> `docs/v2/implementation/exit-gate-phase-7.md` (the exit-gate audit) cross-checked against
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Redundancy Interop Playbook (Phase 6.3 Stream F — task #150)
|
||||
|
||||
> ⚠️ **Historical type names (audited 2026-07-27).** The operator *procedure* below is still the
|
||||
> right one, but `RedundancyPublisherHostedService` (`:22`), `RecoveryStateManager.DwellTime` (`:67`)
|
||||
> and `RedundancyCoordinator` no longer exist — redundancy state is now published by the
|
||||
> cluster-scoped `RedundancyStateActor` singleton with `ServiceLevelCalculator` and
|
||||
> `IRedundancyRoleView`. See `docs/Redundancy.md` (which states plainly that the old types are gone)
|
||||
> and the per-cluster-mesh Phase 6/7 sections of `CLAUDE.md`. The `ServerUriArray` limitation noted
|
||||
> at `:100-105` is still open (SDK object-type gated).
|
||||
|
||||
> **Scope**: manual validation that third-party OPC UA clients + AVEVA MXAccess
|
||||
> observe our non-transparent redundancy signals (ServiceLevel, ServerUriArray,
|
||||
> RedundancySupport) and fail over to the Backup node when the Primary drops.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# v2 Release Readiness
|
||||
|
||||
> ⚠️ **Historical (audited 2026-07-27) — a v2-era snapshot; several "Closed" rows name types that do
|
||||
> not exist in `src/`.** In particular `:41` claims ACL enforcement was "Closed 2026-04-24" via
|
||||
> `AuthorizationBootstrap` / `NodeScopeResolver` / `OpcUaServerService` — **all three have zero
|
||||
> occurrences in `src/`, and there is no per-node ACL gate on any operation** (see `deferment.md`
|
||||
> §3.1 and the banner in `docs/ReadWriteOperations.md`). `RedundancyCoordinator`,
|
||||
> `RedundancyStatePublisher`, `PeerReachabilityTracker`, `GenerationRefreshHostedService`,
|
||||
> `ClusterTopologyLoader` and `SealedBootstrap` are likewise gone. The **unchecked GA exit criteria**
|
||||
> near the end of the file are still live — see `deferment.md` §5.
|
||||
|
||||
> **Last updated**: 2026-04-24 (Phase 5 driver complement closed — AB CIP, AB Legacy, TwinCAT, FOCAS all shipped; FOCAS Tier-C retired for a pure-managed in-process client)
|
||||
> **Status**: **RELEASE-READY (code-path)** for v2 GA. All three original code-path release blockers remain closed. Phase 5 is now complete. Remaining work is manual (live-hardware validations, client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
|
||||
|
||||
@@ -28,11 +37,27 @@ This doc is the single view of where v2 stands against its release criteria. Upd
|
||||
|
||||
All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria.
|
||||
|
||||
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94)
|
||||
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — marked CLOSED 2026-04-19, PR #94)
|
||||
|
||||
> ⚠️ **THIS CLOSURE DOES NOT HOLD (re-verified against source 2026-07-27).** Every type named in this
|
||||
> subsection — `AuthorizationGate`, `NodeScopeResolver`, `AuthorizationBootstrap`, `WriteAuthzPolicy`,
|
||||
> `DriverNodeManager`, `FilterBrowseReferences`, `GateCallMethodRequests`, `MapCallOperation`,
|
||||
> `EquipmentNamespaceContent`, and the `Node:Authorization:*` config keys — has **zero occurrences in
|
||||
> `src/`**. There is no `OnReadValue` hook, no `Browse` override, no `CreateMonitoredItems` override and
|
||||
> no `Call` override in the live node manager, and no HistoryRead path consults any gate.
|
||||
>
|
||||
> Whatever landed on the v2 branch did not survive into the shipped tree. The **only** data-plane
|
||||
> authorization today is two server-wide role checks (`WriteOperate` for writes, `AlarmAck` for alarm
|
||||
> methods); reads, browses, subscriptions and history reads are ungated, and `NodeAcl` rows are
|
||||
> authored and deployed but never evaluated. See [`../security.md`](../security.md) §
|
||||
> Data-Plane Authorization and `deferment.md` §3.1.
|
||||
>
|
||||
> The text below is retained as the historical record of what was *believed* closed. **Read none of it
|
||||
> as current behaviour.**
|
||||
|
||||
**Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
|
||||
|
||||
Remaining Stream C surfaces (hardening, not release-blocking):
|
||||
Remaining Stream C surfaces (hardening, not release-blocking) — **none of these shipped either**:
|
||||
|
||||
- ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired.
|
||||
- ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern).
|
||||
|
||||
+21
-2
@@ -37,7 +37,7 @@ ssh dohertj2@10.100.0.35 'docker ps --filter label=project=lmxopcua --format "{{
|
||||
| Stack dir | Purpose |
|
||||
|---|---|
|
||||
| `/opt/otopcua-mssql` | central SQL (always-on) |
|
||||
| `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` | driver fixtures |
|
||||
| `/opt/otopcua-modbus` · `/opt/otopcua-abcip` · `/opt/otopcua-s7` · `/opt/otopcua-opcuaclient` · `/opt/otopcua-mqtt` · `/opt/otopcua-mtconnect` | driver fixtures |
|
||||
| `~/otopcua-ablegacy` · `~/otopcua-focas` | driver fixtures (user-owned) |
|
||||
| `~/otopcua-harness` | Host.IntegrationTests real-mode deps (SQL + GLAuth) — see §4 |
|
||||
|
||||
@@ -69,9 +69,28 @@ when it isn't running. Bring one up, `dotnet test`, tear down. Repo compose live
|
||||
| S7 | `otopcua-python-snap7` | `10.100.0.35:1102` | `docker compose --profile s7_1500 up -d` |
|
||||
| OpcUaClient | `mcr.microsoft.com/iotedge/opc-plc:2.14.10` | `opc.tcp://10.100.0.35:50000` | `docker compose up -d` |
|
||||
| FOCAS | `otopcua-focas-sim` (vendored focas-mock) | `10.100.0.35:8193` | `docker compose up -d` (stack `~/otopcua-focas`) |
|
||||
| MQTT | `eclipse-mosquitto:2.0.22` (+ a publisher sidecar) | `10.100.0.35:8883` TLS+auth · `:1883` plaintext+auth | `MQTT_FIXTURE_USERNAME=otopcua MQTT_FIXTURE_PASSWORD=<pw> docker compose up -d` (stack `/opt/otopcua-mqtt`) |
|
||||
| MQTT — Sparkplug B | `otopcua-sparkplug-sim` (project-owned C# edge-node simulator) | same broker; group **`OtOpcUaSim`**, edge nodes **`EdgeA`** / **`EdgeB`**, `EdgeA` device **`Filler1`**, ~2 s cadence | `docker compose --profile sparkplug up -d` (same stack). Answers rebirth NCMDs; restart it to force a fresh birth |
|
||||
| MTConnect | `mtconnect/agent:2.7.0.12` + `python:3.13-alpine` adapter | `http://10.100.0.35:5000` | `docker compose up -d --wait` |
|
||||
|
||||
**Endpoint overrides** point a suite at a real PLC instead of the sim:
|
||||
`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT`.
|
||||
`MODBUS_SIM_ENDPOINT` · `AB_SERVER_ENDPOINT` · `S7_SIM_ENDPOINT` · `OPCUA_SIM_ENDPOINT` ·
|
||||
`MQTT_FIXTURE_ENDPOINT` / `MQTT_FIXTURE_PLAIN_ENDPOINT` · `MTCONNECT_AGENT_ENDPOINT`.
|
||||
|
||||
> **MQTT needs credentials + material, unlike the other fixtures.** The broker has **no anonymous
|
||||
> fallback on either listener** (deliberate — an anonymous broker would let a bad auth config pass),
|
||||
> so it will not even start until `./gen-fixture-material.sh` has written `secrets/` (password file +
|
||||
> CA + server cert/key, gitignored) and `MQTT_FIXTURE_USERNAME`/`MQTT_FIXTURE_PASSWORD` are exported
|
||||
> at `docker compose up`. The live suite additionally wants `MQTT_FIXTURE_CA_CERT` pointing at a local
|
||||
> copy of the CA: `scp dohertj2@10.100.0.35:/opt/otopcua-mqtt/secrets/ca.crt /tmp/mqtt-fixture-ca.crt`.
|
||||
> It is also the only fixture whose services carry the `project: lmxopcua` label (see CLAUDE.md).
|
||||
|
||||
> **MTConnect is a TWO-service stack.** The `mtconnect/agent` image ships no simulator, so a lone
|
||||
> Agent answers `/probe` and reports every observation `UNAVAILABLE` forever. The `adapter` service
|
||||
> (a stdlib SHDR feeder, `Docker/adapter.py`) is the data source; the Agent dials out to it. Its
|
||||
> suite also probes over **HTTP**, not TCP — an Agent's port accepts connections before its device
|
||||
> model is parsed. Full detail + the seeded device model:
|
||||
> [`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md).
|
||||
|
||||
> **Fixture-cycling gotcha:** profile-gated services can share a port; a plain `docker compose down`
|
||||
> may leave a stale container bound. Force-remove before switching profiles:
|
||||
|
||||
@@ -29,9 +29,31 @@ public enum BrowseNodeKind
|
||||
/// <c>AlarmExtension</c> primitive). The picker pre-fills a default native-alarm <c>alarm</c> object
|
||||
/// into the TagConfig when an alarm attribute is selected. Defaults to false so non-alarm-aware
|
||||
/// drivers (e.g. the OPC UA client browser) aren't forced to flow a flag they don't produce.</param>
|
||||
/// <param name="AddressFields">
|
||||
/// The <b>structured</b> address this leaf binds by, as <c>TagConfig</c> key → value in the
|
||||
/// driver's own key vocabulary, for a driver whose address is a <i>tuple</i> rather than a single
|
||||
/// reference string. Null (the default) for every driver whose address IS the
|
||||
/// <see cref="BrowseNode.NodeId"/> — nothing changes for them.
|
||||
/// <para>
|
||||
/// It exists because a tuple cannot be recovered from an id string in general. MQTT/Sparkplug
|
||||
/// is the case in point: a browse node id is
|
||||
/// <c>{group}/{node}[/{device}]::{metric}</c>, and a metric name may itself contain <c>/</c>
|
||||
/// (<c>Node Control/Rebirth</c>) — so the session keeps the decomposition it already has and
|
||||
/// <b>states</b> it here, rather than the AdminUI's browse-commit mapper re-deriving it by
|
||||
/// splitting the id and hoping. Same discipline as the v3 address space carrying
|
||||
/// <c>AddressSpaceRealm</c> explicitly instead of parsing it out of a NodeId.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The producer states the binding <em>shape</em> too, by which keys it emits — so a consumer
|
||||
/// never has to infer a driver's sub-mode from the tree's geometry. Consumers must read the
|
||||
/// keys they know and ignore the rest; this is a description, not a config blob to splat into
|
||||
/// a persisted TagConfig.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record AttributeInfo(
|
||||
string Name,
|
||||
string DriverDataType,
|
||||
bool IsArray,
|
||||
string SecurityClass,
|
||||
bool IsAlarm = false);
|
||||
bool IsAlarm = false,
|
||||
IReadOnlyDictionary<string, string>? AddressFields = null);
|
||||
|
||||
@@ -35,3 +35,66 @@ public interface IBrowseSession : IAsyncDisposable
|
||||
/// <returns>The attributes of the node.</returns>
|
||||
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IBrowseSession"/> whose protocol offers an explicit, operator-triggered
|
||||
/// <b>re-announce</b> action: a request that the remote peer republish its self-description so the
|
||||
/// observation window can fill without waiting for the next natural announcement. Implemented
|
||||
/// today only by the MQTT/Sparkplug browser, whose tree is built from observed NBIRTH/DBIRTH
|
||||
/// certificates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is the only interface in the browse contract that causes an outbound message.</b>
|
||||
/// Every other browse member is strictly read-only, and for the MQTT browser that read-only
|
||||
/// property is load-bearing: a picker opened by an operator runs against a live production
|
||||
/// broker. It is a separate interface, rather than an optional member on
|
||||
/// <see cref="IBrowseSession"/>, precisely so "does this session write?" stays a type
|
||||
/// question a caller cannot forget to ask.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Callers must authorize before invoking it.</b> The AdminUI routes it through
|
||||
/// <c>BrowserSessionService.RequestRebirthAsync</c>, which enforces the same
|
||||
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance — an implementation
|
||||
/// cannot check that itself, since it holds no user identity.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IRebirthCapableBrowseSession : IBrowseSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this <i>particular</i> session can actually re-announce — the runtime half of the
|
||||
/// type question, and the one a UI must ask before offering the affordance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One session class may serve several protocol shapes: the MQTT browser opens the same session
|
||||
/// type for Plain and for Sparkplug B, and a Plain MQTT window publishes <b>nothing, ever</b> —
|
||||
/// there is no plain-MQTT re-announce to offer. Implementing the interface therefore means "this
|
||||
/// session type may re-announce"; this property means "this instance will". A UI gating on the
|
||||
/// type alone would draw a button that can only ever throw.
|
||||
/// <para>
|
||||
/// This is <b>not</b> an authorization signal — see the interface remarks. False here hides
|
||||
/// the affordance; the caller still authorizes before invoking
|
||||
/// <see cref="RequestRebirthAsync"/>, and the implementation still refuses a call it cannot
|
||||
/// serve.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
bool RebirthAvailable { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asks the addressed remote peer(s) to re-announce themselves.
|
||||
/// </summary>
|
||||
/// <param name="scope">
|
||||
/// The protocol-specific target. For Sparkplug: a browse <c>NodeId</c> from this session's own
|
||||
/// tree (group, edge node, device or metric — resolved up to the owning edge node), or a bare
|
||||
/// <c>{group}/{edgeNode}</c> pair for a node that has not been observed yet.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The number of request messages published.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="scope"/> is empty or unusable.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The scope resolves to no target, or to more targets than the implementation will fan out to
|
||||
/// in one action. Nothing is published in either case.
|
||||
/// </exception>
|
||||
/// <exception cref="NotSupportedException">This session's mode has no re-announce action.</exception>
|
||||
Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
/// <param name="LastError">Latest error message; null when none.</param>
|
||||
/// <param name="ErrorCount5Min">Number of state-transitions into Faulted in the last 5 minutes.</param>
|
||||
/// <param name="PublishedUtc">Timestamp this snapshot was published.</param>
|
||||
/// <param name="RediscoveryNeededUtc">
|
||||
/// When the driver last raised <c>IRediscoverable.OnRediscoveryNeeded</c> — i.e. it observed that the
|
||||
/// remote's tag set may have changed (a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect
|
||||
/// agent restart, a Sparkplug rebirth). Null when the driver has never raised it, or does not
|
||||
/// implement <c>IRediscoverable</c>.
|
||||
/// <para><b>Advisory only.</b> The served address space is NOT rebuilt in response — raw tags are
|
||||
/// authored explicitly through the <c>/raw</c> browse-commit flow. This is a prompt for an operator
|
||||
/// to re-browse the device and commit whatever changed.</para>
|
||||
/// </param>
|
||||
/// <param name="RediscoveryReason">
|
||||
/// The driver-supplied reason string from the same event (e.g. <c>"deploy-time-changed"</c>), shown
|
||||
/// to the operator alongside the prompt. Null when <paramref name="RediscoveryNeededUtc"/> is null.
|
||||
/// </param>
|
||||
public sealed record DriverHealthChanged(
|
||||
string ClusterId,
|
||||
string DriverInstanceId,
|
||||
@@ -20,7 +33,9 @@ public sealed record DriverHealthChanged(
|
||||
DateTime? LastSuccessfulReadUtc,
|
||||
string? LastError,
|
||||
int ErrorCount5Min,
|
||||
DateTime PublishedUtc)
|
||||
DateTime PublishedUtc,
|
||||
DateTime? RediscoveryNeededUtc = null,
|
||||
string? RediscoveryReason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
|
||||
|
||||
@@ -68,6 +68,8 @@ message DriverHealth {
|
||||
optional string last_error = 5; // nullable in the record
|
||||
int32 error_count_5min = 6;
|
||||
google.protobuf.Timestamp published_utc = 7;
|
||||
google.protobuf.Timestamp rediscovery_needed_utc = 8; // DateTime? — absent Timestamp encodes null
|
||||
optional string rediscovery_reason = 9; // nullable in the record
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverResilienceStatusChanged.
|
||||
|
||||
@@ -60,6 +60,11 @@ public static class DraftValidator
|
||||
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
|
||||
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
|
||||
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
||||
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
|
||||
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
|
||||
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
|
||||
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
|
||||
/// never enters the artifact.</para>
|
||||
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
||||
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
|
||||
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
|
||||
@@ -74,7 +79,7 @@ public static class DraftValidator
|
||||
/// </remarks>
|
||||
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
const string ForbiddenKey = "connectionString";
|
||||
const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
|
||||
|
||||
var sqlInstanceIds = draft.DriverInstances
|
||||
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
||||
@@ -85,7 +90,7 @@ public static class DraftValidator
|
||||
foreach (var d in draft.DriverInstances)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
||||
if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue;
|
||||
if (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) continue;
|
||||
errors.Add(new("SqlConnectionStringPersisted",
|
||||
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
||||
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
||||
@@ -98,7 +103,7 @@ public static class DraftValidator
|
||||
foreach (var dev in draft.Devices)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
||||
if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue;
|
||||
if (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) continue;
|
||||
errors.Add(new("SqlConnectionStringPersisted",
|
||||
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
||||
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
||||
@@ -107,34 +112,6 @@ public static class DraftValidator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
|
||||
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
|
||||
/// and shaping the config JSON is another rule's job.
|
||||
/// </summary>
|
||||
/// <param name="json">The config blob to inspect.</param>
|
||||
/// <param name="key">The property name to look for.</param>
|
||||
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
|
||||
private static bool HasTopLevelKey(string? json, string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
|
||||
foreach (var property in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||
/// <list type="number">
|
||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// The one place that decides whether a persisted config blob carries a literal Sql
|
||||
/// <c>connectionString</c> (Gitea #498 / #499).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A <c>Sql</c> driver names its credentials indirectly — <c>connectionStringRef</c> resolves from
|
||||
/// the environment / secret store at Initialize — so nothing persisted should ever contain a
|
||||
/// database password. The typed <c>SqlDriverConfigDto</c> already drops a <c>connectionString</c>
|
||||
/// key on <b>read</b>, but discarding a secret on read is not the same as refusing to store it:
|
||||
/// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can
|
||||
/// be written even though the runtime ignores it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// There are <b>three</b> surfaces a Sql driver's config is persisted on, and they need different
|
||||
/// enforcement points:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DriverInstance.DriverConfig</c> and <c>Device.DeviceConfig</c> — both in the
|
||||
/// deployed artifact, both visible to <c>DraftSnapshot</c>, both gated by
|
||||
/// <c>DraftValidator</c>.</item>
|
||||
/// <item><c>ClusterNode.DriverConfigOverridesJson</c> — the per-node override map. It is
|
||||
/// <b>not</b> in <c>DraftSnapshot</c>, and deliberately does not go there: adding a
|
||||
/// <c>ClusterNode</c> collection would widen the snapshot (and every builder of one) for a
|
||||
/// single rule about a value that is not part of the artifact at all. More to the point, a
|
||||
/// deploy gate is the wrong instrument here — the artifact never carries node overrides, so
|
||||
/// blocking a deploy would not stop the credential being stored. It is already in the
|
||||
/// database by then. This surface is therefore gated at the <b>save</b>, which is the only
|
||||
/// point where "refuse to store it" is literally true.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately narrow.</b> This checks the <c>Sql</c> driver's <c>connectionString</c> key
|
||||
/// only — not a general "credential-shaped key" sweep over every driver type. A broader rule
|
||||
/// would start refusing configs that are legitimate today for drivers that never made this
|
||||
/// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver
|
||||
/// gains an indirect-credential contract of its own.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SqlCredentialGuard
|
||||
{
|
||||
/// <summary>The config key a Sql driver must never persist. Matched case-insensitively.</summary>
|
||||
public const string ForbiddenKey = "connectionString";
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="configJson"/> is a JSON object carrying <see cref="ForbiddenKey"/> at
|
||||
/// its top level.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds <c>ConnectionString</c> to a
|
||||
/// <c>connectionString</c> property by default, so a case variant is the same key, not a
|
||||
/// different one. Only the top level is scanned — the DTO is flat, so a nested occurrence cannot
|
||||
/// bind and is not the credential-shaped mistake this exists to catch.
|
||||
/// </remarks>
|
||||
/// <param name="configJson">The config blob to inspect.</param>
|
||||
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
|
||||
public static bool CarriesLiteralConnectionString(string? configJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configJson)) return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(configJson);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& HasTopLevelKey(doc.RootElement, ForbiddenKey);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and
|
||||
// throwing here would turn a formatting mistake into a credential-guard failure.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>DriverInstanceId</c>s in a <c>ClusterNode.DriverConfigOverridesJson</c> map whose
|
||||
/// override object carries a literal connection string, restricted to instances that are actually
|
||||
/// Sql drivers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The override map is shaped <c>{ "<DriverInstanceId>": { …driver config keys… } }</c> and is
|
||||
/// merged onto the cluster-level <c>DriverConfig</c>, so a credential pasted into one lands in
|
||||
/// exactly the place <see cref="CarriesLiteralConnectionString"/> already refuses on the driver
|
||||
/// itself.
|
||||
/// </remarks>
|
||||
/// <param name="overridesJson">The node's override map. Null / blank / malformed yields no
|
||||
/// violations.</param>
|
||||
/// <param name="sqlDriverInstanceIds">The ids of driver instances whose <c>DriverType</c> is
|
||||
/// <c>Sql</c>. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential
|
||||
/// guarantee, so flagging it would be a regression, not defence in depth.</param>
|
||||
/// <returns>The offending driver-instance ids, in document order; empty when there are none.</returns>
|
||||
public static IReadOnlyList<string> FindNodeOverrideViolations(
|
||||
string? overridesJson, IReadOnlyCollection<string> sqlDriverInstanceIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sqlIds = sqlDriverInstanceIds as IReadOnlySet<string>
|
||||
?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(overridesJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return [];
|
||||
|
||||
List<string>? offenders = null;
|
||||
foreach (var entry in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!sqlIds.Contains(entry.Name)) continue;
|
||||
if (entry.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue;
|
||||
|
||||
(offenders ??= []).Add(entry.Name);
|
||||
}
|
||||
|
||||
return (IReadOnlyList<string>?)offenders ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="obj"/> carries <paramref name="key"/> at its top level,
|
||||
/// case-insensitively.</summary>
|
||||
private static bool HasTopLevelKey(JsonElement obj, string key)
|
||||
{
|
||||
foreach (var property in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
|
||||
/// constants and the set the driver factories actually register, so a rename on
|
||||
/// either side breaks the build's test gate. Only constants for
|
||||
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver
|
||||
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own
|
||||
/// constant when its factory is wired in.
|
||||
/// <b>currently-registered</b> factories belong here — a driver adds its own constant
|
||||
/// when its factory is wired in. (This used to name Calculation as the example of a
|
||||
/// not-yet-registered driver; it registers in <c>DriverFactoryBootstrap</c> like the rest.)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DriverTypeNames
|
||||
@@ -55,6 +55,11 @@ public static class DriverTypeNames
|
||||
|
||||
/// <summary>Read-only SQL Server table/view poller — publishes columns as OPC UA variables.</summary>
|
||||
public const string Sql = "Sql";
|
||||
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
|
||||
public const string Mqtt = "Mqtt";
|
||||
|
||||
/// <summary>MTConnect Agent driver — read-only HTTP/XML over an Agent's probe/current/sample surface.</summary>
|
||||
public const string MTConnect = "MTConnect";
|
||||
|
||||
/// <summary>
|
||||
/// Every driver-type string declared above, for callers that need to enumerate
|
||||
@@ -72,5 +77,7 @@ public static class DriverTypeNames
|
||||
Galaxy,
|
||||
Calculation,
|
||||
Sql,
|
||||
Mqtt,
|
||||
MTConnect,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,11 +15,20 @@ public interface IDriverHealthPublisher
|
||||
/// <param name="driverInstanceId">The driver instance the snapshot describes.</param>
|
||||
/// <param name="health">The current health snapshot.</param>
|
||||
/// <param name="errorCount5Min">The number of errors observed in the trailing 5-minute window.</param>
|
||||
/// <param name="rediscoveryNeededUtc">
|
||||
/// When the driver last raised <see cref="IRediscoverable.OnRediscoveryNeeded"/>, or null if never
|
||||
/// (or if it is not an <see cref="IRediscoverable"/>). Advisory: it prompts an operator to
|
||||
/// re-browse, and never rebuilds the served address space.
|
||||
/// </param>
|
||||
/// <param name="rediscoveryReason">The driver-supplied reason from that event; null when
|
||||
/// <paramref name="rediscoveryNeededUtc"/> is null.</param>
|
||||
void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min);
|
||||
int errorCount5Min,
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +47,8 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min)
|
||||
int errorCount5Min,
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null)
|
||||
{ /* no-op */ }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,32 @@ public interface ITagDiscovery
|
||||
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
|
||||
/// </summary>
|
||||
bool SupportsOnlineDiscovery => false;
|
||||
|
||||
/// <summary>
|
||||
/// The device-native references this driver's <b>authored</b> tags bind to — the same vocabulary
|
||||
/// <see cref="DiscoverAsync"/> streams as <see cref="DriverAttributeInfo.FullName"/>. Comparing the
|
||||
/// two sets detects that the remote's tag set has drifted from what an operator authored.
|
||||
/// <para><b>Null means "I do not report this", and drift detection is skipped.</b> That is the
|
||||
/// default on purpose: an empty collection legitimately means "no tags authored, so everything
|
||||
/// discovered is new", and the two must not be confused. A driver opting in is asserting that its
|
||||
/// authored refs are directly comparable to what it discovers.</para>
|
||||
/// <para>Only consulted when <see cref="SupportsOnlineDiscovery"/> is true. For a driver that
|
||||
/// replays authored tags from config rather than browsing the backend, the comparison is a
|
||||
/// tautology — it would report "no drift" forever and read as reassurance.</para>
|
||||
/// </summary>
|
||||
IReadOnlyCollection<string>? AuthoredDiscoveryRefs => null;
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="DiscoverAsync"/> re-emits this driver's <b>authored</b> tags into the same
|
||||
/// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker
|
||||
/// shows an operator their existing tags alongside what the device offers.
|
||||
/// <para><b>This makes one half of drift detection structurally undetectable.</b> If authored tags
|
||||
/// are always re-emitted then the discovered set always contains the authored set, so an authored tag
|
||||
/// that has VANISHED from the device can never appear missing. Declaring it here means the detector
|
||||
/// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading
|
||||
/// as reassurance.</para>
|
||||
/// <para>False (the default) means the stream is purely device-derived — as MTConnect's is, built
|
||||
/// from the Agent's probe model — so both directions are detectable.</para>
|
||||
/// </summary>
|
||||
bool DiscoveryStreamIncludesAuthoredTags => false;
|
||||
}
|
||||
|
||||
@@ -1036,6 +1036,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Pre-declared tags are emitted under their <c>Name</c>, which is the driver FullName the
|
||||
/// controller-browse leaves also use.</remarks>
|
||||
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
|
||||
_declaredTags.Select(t => t.Name).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>True: <c>DiscoverAsync</c> emits browsed controller symbols AND re-emits every
|
||||
/// pre-declared tag.</remarks>
|
||||
public bool DiscoveryStreamIncludesAuthoredTags => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbLegacyDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
|
||||
private AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly ILogger<AbLegacyDriver> _logger;
|
||||
@@ -94,12 +96,28 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbLegacy";
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
|
||||
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
|
||||
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with, so an operator's edit is discarded while the deployment still seals green.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
||||
|
||||
@@ -49,6 +49,23 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
|
||||
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
return new AbLegacyDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
tagFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an AB Legacy <c>DriverConfig</c> JSON document into typed options. Extracted so
|
||||
/// <see cref="AbLegacyDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
|
||||
/// (Gitea #516) instead of serving the options it was constructed with — which silently discarded
|
||||
/// every operator edit while the deployment still sealed green.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>The parsed <see cref="AbLegacyDriverOptions"/>.</returns>
|
||||
internal static AbLegacyDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -82,10 +99,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
};
|
||||
|
||||
return new AbLegacyDriver(
|
||||
options, driverInstanceId,
|
||||
tagFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
|
||||
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
|
||||
private FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IFocasClientFactory _clientFactory;
|
||||
/// <summary>Mutable for the same reason as <see cref="_options"/> — the <c>Backend</c> config key
|
||||
/// selects it, so the two must move together or a new tag set polls through an old backend.</summary>
|
||||
private IFocasClientFactory _clientFactory;
|
||||
|
||||
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
|
||||
/// was constructed directly (tests, or a caller supplying its own backend), which keeps the
|
||||
/// constructor-supplied options authoritative — exactly the prior behaviour.</summary>
|
||||
private readonly Func<string, FocasDriverBinding>? _rebind;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly ILogger<FocasDriver> _logger;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -58,14 +67,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
|
||||
/// <param name="logger">Optional logger instance.</param>
|
||||
/// <param name="rebind">Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency,
|
||||
/// supplied by the factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct
|
||||
/// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied
|
||||
/// options + backend.</param>
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null,
|
||||
ILogger<FocasDriver>? logger = null)
|
||||
ILogger<FocasDriver>? logger = null,
|
||||
Func<string, FocasDriverBinding>? rebind = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||
_rebind = rebind;
|
||||
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
@@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
backoffCap: PollBackoffCap);
|
||||
}
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
|
||||
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
|
||||
/// without a config — those keep the constructor-supplied options + backend.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -100,12 +125,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Opens the configured CNC handles and builds the authored tag table.
|
||||
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
|
||||
/// the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
|
||||
/// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
|
||||
/// and are adopted together. That pairing is the point — adopting options alone would poll a NEW
|
||||
/// device/tag set through the OLD backend, which is why this driver was excluded from the first
|
||||
/// #516 pass.</para>
|
||||
/// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
|
||||
/// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
|
||||
/// remains the outer guarantee.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
{
|
||||
// #516: adopt a changed config. Options and the backend client factory are re-derived by ONE
|
||||
// parse and assigned together — a partial adoption would poll a new device/tag set through the
|
||||
// old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the
|
||||
// constructor-supplied pair, which is what every lifecycle test passing "{}" relies on.
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
_options = binding.Options;
|
||||
_clientFactory = binding.ClientFactory;
|
||||
}
|
||||
|
||||
// Fail fast if the factory is a stub/unimplemented backend — the operator must
|
||||
// see an actionable error at init rather than a phantom-Healthy driver that fails
|
||||
// every read/write/subscribe silently.
|
||||
@@ -467,6 +517,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// until the node set is non-empty and stable.</summary>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The authored tag's <c>Name</c> IS its driver-side FullName — <c>DiscoverAsync</c> emits
|
||||
/// <c>FullName: tag.Name</c> for every authored tag — so the two sets are directly comparable.</remarks>
|
||||
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
|
||||
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>True: <c>DiscoverAsync</c> emits the device-derived FixedTree AND re-emits every authored
|
||||
/// tag, so an authored tag that vanished from the CNC cannot appear missing.</remarks>
|
||||
public bool DiscoveryStreamIncludesAuthoredTags => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions
|
||||
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
||||
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
|
||||
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
var binding = ParseBinding(driverInstanceId, driverConfigJson);
|
||||
return new FocasDriver(
|
||||
binding.Options,
|
||||
driverInstanceId,
|
||||
binding.ClientFactory,
|
||||
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
|
||||
// config-derived dependency on a reinitialize — options AND the backend client factory
|
||||
// together (#516). Re-parsing options alone would run a new device/tag set against the old
|
||||
// backend, which is worse than not re-parsing at all.
|
||||
rebind: json => ParseBinding(driverInstanceId, json));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a FOCAS <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
|
||||
/// from config: the typed options and the <see cref="IFocasClientFactory"/> the <c>Backend</c> key
|
||||
/// selects. Returned together because they must be adopted together — see the <c>rebind</c> note in
|
||||
/// <see cref="CreateInstance"/>.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
||||
/// <returns>The parsed options + backend factory.</returns>
|
||||
internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions
|
||||
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
|
||||
};
|
||||
|
||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions
|
||||
public TimeSpan? Interval { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything <c>FocasDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
|
||||
/// reinitialize adopts all of it atomically.
|
||||
/// <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
|
||||
/// <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
|
||||
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
|
||||
/// </summary>
|
||||
/// <param name="Options">The typed driver options.</param>
|
||||
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
|
||||
public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory);
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Frozen;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The type shape inferred for a single MTConnect <c>DataItem</c>: the scalar
|
||||
/// <see cref="DriverDataType" /> plus the array shape that
|
||||
/// <see cref="DriverAttributeInfo.IsArray" /> / <see cref="DriverAttributeInfo.ArrayDim" />
|
||||
/// expect. Returned by value (a readonly record struct) so the discovery loop and the
|
||||
/// AdminUI editor can call the inference on a hot path without allocating.
|
||||
/// </summary>
|
||||
/// <param name="DataType">The scalar element type of the observation.</param>
|
||||
/// <param name="IsArray">
|
||||
/// <c>true</c> only for a <c>SAMPLE</c> declared <c>representation="TIME_SERIES"</c>, whose
|
||||
/// observation carries a vector of samples rather than a single value.
|
||||
/// </param>
|
||||
/// <param name="ArrayDim">
|
||||
/// The probe-declared array length when <see cref="IsArray" /> is <c>true</c> and the device
|
||||
/// model declared a positive <c>sampleCount</c>; <c>null</c> for a variable-length series
|
||||
/// (many agents omit <c>sampleCount</c>) and always <c>null</c> for a scalar.
|
||||
/// </param>
|
||||
public readonly record struct MTConnectInferredType(DriverDataType DataType, bool IsArray, uint? ArrayDim);
|
||||
|
||||
/// <summary>
|
||||
/// Translates an MTConnect <c>DataItem</c>'s device-model metadata (category / type / units /
|
||||
/// representation) into the server's <see cref="DriverDataType" />. Pure and deterministic —
|
||||
/// no I/O, no logging, no mutable state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a
|
||||
/// tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c>
|
||||
/// (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the
|
||||
/// universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the
|
||||
/// AdminUI typed tag editor (shows the inferred type and offers an override).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
|
||||
/// defines hundreds of <c>type</c> values with more added each revision. This is therefore a
|
||||
/// <i>rule</i>, not an enumeration, and the result is stored per tag and author-overridable.
|
||||
/// The rule is asymmetric on purpose: a wrong <i>numeric</i> guess produces a value that
|
||||
/// fails to parse and surfaces as Bad quality at runtime, whereas <see cref="DriverDataType.String" />
|
||||
/// always round-trips and the author can retype it. So every default leans to
|
||||
/// <c>String</c> except where the standard itself guarantees a number.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Defaulting rule, per category</b> (design §3.3):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <c>SAMPLE</c> ⇒ <see cref="DriverDataType.Float64" />, <i>including for an
|
||||
/// unrecognised or missing <c>type</c></i>. The standard defines the whole SAMPLE
|
||||
/// category as a continuously-varying measured quantity, so the category alone is
|
||||
/// the guarantee — a missing <c>units</c> attribute is a gap in the device model,
|
||||
/// not evidence that the observation is text. <c>Float64</c> rather than an integer
|
||||
/// type because a double parse accepts both <c>3</c> and <c>3.5</c>.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <c>EVENT</c> ⇒ <see cref="DriverDataType.String" /> by default, with a small
|
||||
/// exception list (<see cref="NumericEventTypes" />) of standard types that are
|
||||
/// defined as integers. The overwhelming majority of EVENT types are controlled
|
||||
/// vocabulary (<c>Execution</c>, <c>ControllerMode</c>, <c>Availability</c>) or free
|
||||
/// text (<c>Program</c>, <c>Block</c>, <c>Message</c>), and the exception list is
|
||||
/// deliberately short — each entry is a coercion risk, while every omission is
|
||||
/// merely a string the author can retype.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <c>CONDITION</c> ⇒ <see cref="DriverDataType.String" /> always. The observation
|
||||
/// is a state word (<c>Normal</c> / <c>Warning</c> / <c>Fault</c> /
|
||||
/// <c>Unavailable</c>), never a number, regardless of the item's <c>type</c>.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// An <b>unknown, blank, or missing category</b> ⇒ <see cref="DriverDataType.String" />.
|
||||
/// Probe XML in the wild is inconsistent; with no category we have no evidence the
|
||||
/// observation is numeric, so we pick the type that cannot fail to parse.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Representation overrides the category where the two disagree about shape:
|
||||
/// <c>DATA_SET</c> / <c>TABLE</c> observations are key-value maps, so they demote to
|
||||
/// <see cref="DriverDataType.String" /> even on a <c>SAMPLE</c>; <c>TIME_SERIES</c> is
|
||||
/// defined only for <c>SAMPLE</c> and is ignored (not treated as an array) elsewhere;
|
||||
/// <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Matching is case-insensitive AND separator-insensitive</b> on every input —
|
||||
/// <c>_</c>, <c>-</c> and whitespace are ignored wherever they appear, so
|
||||
/// <c>PART_COUNT</c> ≡ <c>PartCount</c> ≡ <c>part-count</c> ≡ <c>partcount</c>. This is not
|
||||
/// mere leniency: <b>MTConnect spells the same concept two different ways in two different
|
||||
/// documents</b>. The Devices document (<c>/probe</c>) writes <c>DataItem@type</c> in
|
||||
/// UPPER_SNAKE (<c>PART_COUNT</c>, <c>POSITION</c>, <c>PATH_FEEDRATE</c>), while the Streams
|
||||
/// documents (<c>/current</c>, <c>/sample</c>) name the observation element in PascalCase
|
||||
/// (<c>PartCount</c>, <c>Position</c>). Discovery reads the <i>probe</i> spelling, and plain
|
||||
/// case-insensitivity does not bridge the underscore — so matching only the PascalCase
|
||||
/// spelling types every real agent's <c>PART_COUNT</c> as <see cref="DriverDataType.String" />
|
||||
/// while every unit test using the sketch's PascalCase spelling stays green.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MTConnectDataTypeInference
|
||||
{
|
||||
private const string CategorySample = "SAMPLE";
|
||||
private const string CategoryEvent = "EVENT";
|
||||
|
||||
private const string RepresentationTimeSeries = "TIME_SERIES";
|
||||
private const string RepresentationDataSet = "DATA_SET";
|
||||
private const string RepresentationTable = "TABLE";
|
||||
|
||||
/// <summary>
|
||||
/// The <c>EVENT</c> types the standard defines as integers — the exception list to the
|
||||
/// "EVENT is text" default. Kept deliberately short: adding a type here is a claim that
|
||||
/// every agent reports it as a parseable integer, and being wrong costs Bad quality.
|
||||
/// <c>Line</c> is the deprecated spelling that <c>LineNumber</c> superseded in MTConnect 1.4;
|
||||
/// both are mapped so a current-version agent is not silently mistyped.
|
||||
/// <para>
|
||||
/// The entries are written in the Streams (PascalCase) spelling, but the set's comparer
|
||||
/// is <see cref="SeparatorInsensitiveComparer" />, so the probe document's
|
||||
/// <c>PART_COUNT</c> / <c>LINE_NUMBER</c> hit the same entries. Adding a spelling variant
|
||||
/// here would be redundant, not additional coverage.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static readonly FrozenSet<string> NumericEventTypes =
|
||||
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(SeparatorInsensitiveComparer.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model
|
||||
/// metadata. See the type-level remarks for the full rule and its rationale.
|
||||
/// </summary>
|
||||
/// <param name="category">
|
||||
/// The <c>DataItem</c> category — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.
|
||||
/// Case-insensitive; an unknown, blank, or <c>null</c> value yields
|
||||
/// <see cref="DriverDataType.String" />.
|
||||
/// </param>
|
||||
/// <param name="type">
|
||||
/// The <c>DataItem</c> type, in <b>either</b> document's spelling — the probe's
|
||||
/// <c>PART_COUNT</c> / <c>POSITION</c> or the streams' <c>PartCount</c> / <c>Position</c>.
|
||||
/// Matched case- and separator-insensitively (see the type-level remarks); an unrecognised
|
||||
/// or <c>null</c> value falls back to the category default.
|
||||
/// </param>
|
||||
/// <param name="units">
|
||||
/// The declared engineering units, if any. Accepted for signature stability and because every
|
||||
/// call site already holds it, but <b>not load-bearing in v1</b>: <c>SAMPLE</c> is numeric by
|
||||
/// definition of the category, so units add no discriminating power there, and treating a
|
||||
/// units-bearing <c>EVENT</c> as numeric would be exactly the risky coercion this rule avoids.
|
||||
/// </param>
|
||||
/// <param name="representation">
|
||||
/// The <c>DataItem</c> representation — <c>VALUE</c> (default), <c>TIME_SERIES</c>,
|
||||
/// <c>DATA_SET</c>, <c>TABLE</c>, or <c>DISCRETE</c>. Case-insensitive.
|
||||
/// </param>
|
||||
/// <param name="sampleCount">
|
||||
/// The probe-declared <c>sampleCount</c> for a <c>TIME_SERIES</c> item. Flows into
|
||||
/// <see cref="MTConnectInferredType.ArrayDim" /> when positive; <c>null</c> or a non-positive
|
||||
/// value yields a variable-length array (<c>ArrayDim = null</c>). Ignored for scalars.
|
||||
/// </param>
|
||||
/// <returns>The inferred scalar type plus array shape.</returns>
|
||||
public static MTConnectInferredType Infer(
|
||||
string? category,
|
||||
string? type = null,
|
||||
string? units = null,
|
||||
string? representation = null,
|
||||
int? sampleCount = null)
|
||||
{
|
||||
_ = units; // See the parameter doc: intentionally not load-bearing in v1.
|
||||
|
||||
var trimmedCategory = category.AsSpan().Trim();
|
||||
var trimmedRepresentation = representation.AsSpan().Trim();
|
||||
|
||||
// A key-value observation is never a number, whatever the category claims. Checked first so
|
||||
// a DATA_SET SAMPLE cannot slip through as Float64 and go Bad on every parse.
|
||||
if (Matches(trimmedRepresentation, RepresentationDataSet) || Matches(trimmedRepresentation, RepresentationTable))
|
||||
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
|
||||
|
||||
if (Matches(trimmedCategory, CategorySample))
|
||||
{
|
||||
// TIME_SERIES is defined for SAMPLE only; the vector is a vector of the same scalar type.
|
||||
if (Matches(trimmedRepresentation, RepresentationTimeSeries))
|
||||
return new MTConnectInferredType(
|
||||
DriverDataType.Float64,
|
||||
IsArray: true,
|
||||
ArrayDim: sampleCount is > 0 ? (uint)sampleCount.Value : null);
|
||||
|
||||
return new MTConnectInferredType(DriverDataType.Float64, IsArray: false, ArrayDim: null);
|
||||
}
|
||||
|
||||
if (Matches(trimmedCategory, CategoryEvent))
|
||||
{
|
||||
// No Trim() here: the set's comparer already ignores whitespace and separators
|
||||
// wherever they appear, so the raw attribute value is looked up allocation-free.
|
||||
var dataType = type is { Length: > 0 } && NumericEventTypes.Contains(type)
|
||||
? DriverDataType.Int64
|
||||
: DriverDataType.String;
|
||||
|
||||
return new MTConnectInferredType(dataType, IsArray: false, ArrayDim: null);
|
||||
}
|
||||
|
||||
// CONDITION (a state word) and every unknown / blank / missing category.
|
||||
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
|
||||
}
|
||||
|
||||
private static bool Matches(ReadOnlySpan<char> value, string expected)
|
||||
=> value.Equals(expected, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Ordinal-ignore-case equality that additionally skips <c>_</c>, <c>-</c> and whitespace
|
||||
/// wherever they occur, so the probe document's <c>PART_COUNT</c> and the streams
|
||||
/// document's <c>PartCount</c> are one key. Backs <see cref="NumericEventTypes" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Comparing through a comparer rather than normalising the input keeps the lookup
|
||||
/// allocation-free on the discovery hot path: no <c>string.Replace</c>, no
|
||||
/// <c>Trim()</c>, no temporary buffer — the raw attribute value from the parser is passed
|
||||
/// straight to <see cref="FrozenSet{T}.Contains" />.
|
||||
/// </remarks>
|
||||
private sealed class SeparatorInsensitiveComparer : IEqualityComparer<string>
|
||||
{
|
||||
/// <summary>The single shared instance; the comparer is stateless.</summary>
|
||||
internal static readonly SeparatorInsensitiveComparer Instance = new();
|
||||
|
||||
private SeparatorInsensitiveComparer() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(string? x, string? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return true;
|
||||
if (x is null || y is null) return false;
|
||||
|
||||
int i = 0, j = 0;
|
||||
while (true)
|
||||
{
|
||||
while (i < x.Length && IsSeparator(x[i])) i++;
|
||||
while (j < y.Length && IsSeparator(y[j])) j++;
|
||||
|
||||
if (i == x.Length || j == y.Length) return i == x.Length && j == y.Length;
|
||||
if (char.ToUpperInvariant(x[i]) != char.ToUpperInvariant(y[j])) return false;
|
||||
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetHashCode(string obj)
|
||||
{
|
||||
// Must hash exactly the characters Equals compares, or two equal spellings land in
|
||||
// different buckets and the set silently misses.
|
||||
var hash = new HashCode();
|
||||
foreach (var c in obj)
|
||||
{
|
||||
if (IsSeparator(c)) continue;
|
||||
hash.Add(char.ToUpperInvariant(c));
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
|
||||
private static bool IsSeparator(char c) => c is '_' or '-' || char.IsWhiteSpace(c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// MTConnect Agent driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
|
||||
/// <c>DriverHost.RegisterAsync</c>. The driver polls a remote MTConnect Agent's REST endpoints
|
||||
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>) rooted at <see cref="AgentUri"/> and maps
|
||||
/// each returned DataItem into an OPC UA variable per <see cref="MTConnectTagDefinition"/>.
|
||||
/// </summary>
|
||||
public sealed class MTConnectDriverOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the MTConnect Agent's base URI (e.g. <c>http://agent:5000</c>). Required — the
|
||||
/// driver appends the standard Agent request paths (<c>/probe</c>, <c>/current</c>,
|
||||
/// <c>/sample</c>) to this base.
|
||||
/// </summary>
|
||||
public required string AgentUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional device-name scope. When set, requests are narrowed to
|
||||
/// <c>{AgentUri}/{DeviceName}/...</c> so a multi-device Agent only serves one device's
|
||||
/// DataItems through this driver instance. Default <c>null</c> = agent-wide (all devices).
|
||||
/// </summary>
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-call HTTP deadline, in milliseconds, applied to every Agent request
|
||||
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). Default <c>5000</c> — long enough for
|
||||
/// a large device probe response over a LAN, short enough that a hung Agent surfaces as a
|
||||
/// failed poll rather than wedging the driver.
|
||||
/// </summary>
|
||||
public int RequestTimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>interval</c> query parameter (milliseconds) passed to the Agent's <c>/sample</c>
|
||||
/// streaming request — the minimum time the Agent waits between successive chunks of the
|
||||
/// response. Default <c>1000</c>; must be non-zero so the sample pump cannot spin the
|
||||
/// connection into a busy-loop.
|
||||
/// </summary>
|
||||
public int SampleIntervalMs { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>count</c> query parameter passed to the Agent's <c>/sample</c> request — the
|
||||
/// maximum number of DataItem observations returned per chunk. Default <c>1000</c>, the
|
||||
/// MTConnect Agent's own conventional default.
|
||||
/// </summary>
|
||||
public int SampleCount { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>heartbeat</c> query parameter (milliseconds) passed to the Agent's <c>/sample</c>
|
||||
/// streaming request — the Agent sends an empty chunk after this much idle time so the
|
||||
/// client can detect a silently-stalled connection. Default <c>10000</c>, matching the
|
||||
/// MTConnect Agent's own conventional default; must be non-zero or a quiet connection can
|
||||
/// never be distinguished from a dead one.
|
||||
/// </summary>
|
||||
public int HeartbeatMs { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// The authored tags this driver instance serves — one <see cref="MTConnectTagDefinition"/>
|
||||
/// per tag, keyed by the DataItem <c>id</c> it binds
|
||||
/// (<see cref="MTConnectTagDefinition.FullName"/>). The factory config DTO carries these and
|
||||
/// builds them into the options; the driver indexes them to know each tag's target
|
||||
/// <see cref="MTConnectTagDefinition.DriverDataType"/> when coercing an Agent observation
|
||||
/// (whose wire form is always text) into a published value.
|
||||
/// <para>
|
||||
/// Defaults to <b>empty, never <c>null</c></b>: a driver instance may legitimately be
|
||||
/// authored with no tags yet, and a null collection here would surface as a
|
||||
/// NullReferenceException at deploy time from operator-authored config.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<MTConnectTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// <b>The v3 data-plane binding: the authored raw tags the deploy artifact delivers.</b> Each
|
||||
/// <see cref="RawTagEntry"/> pairs the tag's <b>RawPath</b> — the driver's wire reference for
|
||||
/// read / subscribe / publish, and the key <c>DriverHostActor</c> routes published values by —
|
||||
/// with the driver-specific <c>TagConfig</c> blob naming the Agent DataItem <c>id</c> it binds.
|
||||
/// Injected into every driver's merged config by <c>DriverDeviceConfigMerger</c>, so this is the
|
||||
/// collection a deployed instance actually serves from.
|
||||
/// <para>
|
||||
/// <b>Relationship to <see cref="Tags"/>.</b> The driver builds ONE observation index and
|
||||
/// ONE <c>RawPath → dataItemId</c> table from both collections:
|
||||
/// <list type="bullet">
|
||||
/// <item>A tag in <c>RawTags</c> is reachable by its RawPath (production). Its coercion
|
||||
/// type comes from the blob's <c>driverDataType</c>; failing that, from a
|
||||
/// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's
|
||||
/// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing
|
||||
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.</item>
|
||||
/// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c>
|
||||
/// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes
|
||||
/// its coercion type to the index.</item>
|
||||
/// </list>
|
||||
/// Both default to empty, so neither collection is required and a driver instance
|
||||
/// authored with no tags yet starts cleanly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. When <see cref="MTConnectProbeOptions.Enabled"/>
|
||||
/// is true the driver periodically issues a cheap <c>/probe</c> request and raises
|
||||
/// <c>OnHostStatusChanged</c> on Running ↔ Stopped transitions.
|
||||
/// </summary>
|
||||
public MTConnectProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Reconnect backoff settings used after a failed Agent request or a dropped
|
||||
/// <c>/sample</c> streaming connection.
|
||||
/// </summary>
|
||||
public MTConnectReconnectOptions Reconnect { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Background connectivity-probe knobs, mirroring <c>ModbusProbeOptions</c>.</summary>
|
||||
public sealed class MTConnectProbeOptions
|
||||
{
|
||||
/// <summary>Gets a value indicating whether probing is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
/// <summary>Gets the interval between probe requests.</summary>
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
/// <summary>Gets the probe request timeout.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>Geometric-backoff settings for the post-failure reconnect loop, mirroring <c>ModbusReconnectOptions</c>.</summary>
|
||||
public sealed class MTConnectReconnectOptions
|
||||
{
|
||||
/// <summary>Delay before the first reconnect attempt, in milliseconds. Default <c>0</c> = immediate.</summary>
|
||||
public int MinBackoffMs { get; init; } = 0;
|
||||
/// <summary>Upper bound on the geometric backoff sequence, in milliseconds.</summary>
|
||||
public int MaxBackoffMs { get; init; } = 30000;
|
||||
/// <summary>Multiplier applied each retry. Default <c>2.0</c> doubles each step.</summary>
|
||||
public double BackoffMultiplier { get; init; } = 2.0;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// One MTConnect-backed OPC UA variable. Each authored tag binds a single Agent DataItem by
|
||||
/// its <c>id</c> attribute (<see cref="FullName"/>); the driver looks up the observation's
|
||||
/// current value from the Agent's <c>/current</c> snapshot / <c>/sample</c> stream by that id.
|
||||
/// </summary>
|
||||
/// <param name="FullName">
|
||||
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is
|
||||
/// the driver's reference for read/subscribe — MTConnect is a read-only source protocol, so
|
||||
/// there is no corresponding write path.
|
||||
/// </param>
|
||||
/// <param name="DriverDataType">Logical data type the DataItem's value is coerced to.</param>
|
||||
/// <param name="IsArray">
|
||||
/// When <c>true</c>, the tag is exposed as an OPC UA array (e.g. an MTConnect
|
||||
/// <c>PathPosition</c> / vector-valued sample). Default <c>false</c> = scalar.
|
||||
/// </param>
|
||||
/// <param name="ArrayDim">
|
||||
/// Element count when <see cref="IsArray"/> is <c>true</c>; ignored for scalar tags. Default
|
||||
/// <c>0</c>.
|
||||
/// </param>
|
||||
/// <param name="MtCategory">
|
||||
/// The DataItem's MTConnect <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or
|
||||
/// <c>CONDITION</c>. Optional metadata carried through for browse / display; not consulted by
|
||||
/// the value-mapping path. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="MtType">
|
||||
/// The DataItem's MTConnect <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).
|
||||
/// Optional metadata. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="MtSubType">
|
||||
/// The DataItem's MTConnect <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>).
|
||||
/// Optional metadata. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="Units">
|
||||
/// The DataItem's MTConnect <c>units</c> attribute (e.g. <c>MILLIMETER</c>,
|
||||
/// <c>REVOLUTION/MINUTE</c>). Optional metadata. Default <c>null</c>.
|
||||
/// </param>
|
||||
public sealed record MTConnectTagDefinition(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
bool IsArray = false,
|
||||
int ArrayDim = 0,
|
||||
string? MtCategory = null,
|
||||
string? MtType = null,
|
||||
string? MtSubType = null,
|
||||
string? Units = null);
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Pure records + the MTConnect data-type inference table, shared by the driver, browse-commit,
|
||||
and the AdminUI typed tag editor. No backend NuGet — only the zero-dependency Core.Abstractions
|
||||
leaf (DriverDataType etc.), mirroring Driver.Modbus.Contracts / Driver.AbCip.Contracts. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,135 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
|
||||
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). This interface is transport-neutral by
|
||||
/// design — every method returns/yields the plain DTOs in <c>MTConnectDtos.cs</c>, never a
|
||||
/// TrakHound type, an <c>XElement</c>, or any other wire/library artifact. That is what lets
|
||||
/// every driver behaviour above this seam (Tasks 6–13) be unit-tested against a fake serving
|
||||
/// canned probe/current/sample XML, with no sockets involved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The production implementation, <see cref="MTConnectAgentClient"/>, carries no backend NuGet
|
||||
/// at all: it is an <see cref="HttpClient"/> over the Agent's three request paths, feeding
|
||||
/// hand-rolled <c>System.Xml.Linq</c> parsers (<c>MTConnectProbeParser</c> /
|
||||
/// <c>MTConnectStreamsParser</c>) plus a <c>multipart/x-mixed-replace</c> frame reader for the
|
||||
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
|
||||
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
|
||||
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
|
||||
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
|
||||
/// protocol rule that lives here, rather than on the implementation, so a consumer written
|
||||
/// against this seam never has to reference the concrete client.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the seam is <see cref="IDisposable"/> rather than
|
||||
/// <see cref="IAsyncDisposable"/>.</b> The driver obtains its client through a
|
||||
/// <c>Func<MTConnectDriverOptions, IMTConnectAgentClient></c> and must release it on
|
||||
/// <c>ShutdownAsync</c> and on every endpoint-changing <c>ReinitializeAsync</c>. Declaring
|
||||
/// disposal here — instead of the driver doing <c>as IDisposable</c> + a null-conditional
|
||||
/// call — is deliberate: that idiom silently no-ops against any future non-disposable
|
||||
/// implementation, and the thing it would leak is an <see cref="HttpClient"/> and its socket
|
||||
/// handler, once per re-deploy, for the life of the process.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Synchronous disposal is the honest shape because <i>every</i> resource behind this seam is
|
||||
/// synchronously disposable — two <see cref="HttpClient"/>s and a
|
||||
/// <see cref="CancellationTokenSource"/>. An <see cref="IAsyncDisposable"/> here would be a
|
||||
/// synchronous method wearing a <c>ValueTask</c>, and it would oblige every canned-XML fake
|
||||
/// to grow one too. The <b>async</b> unwind that a streaming client does need is already
|
||||
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync"/> enumerator
|
||||
/// is itself <see cref="IAsyncDisposable"/> and is owned by the pump that enumerates it,
|
||||
/// while <see cref="IDisposable.Dispose"/> on the client converts any read still in flight
|
||||
/// into an ordinary <see cref="OperationCanceledException"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMTConnectAgentClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Issues an Agent <c>/probe</c> request and returns the parsed device hierarchy. Called
|
||||
/// once at driver initialization; the result is cached and later walked to build the OPC UA
|
||||
/// browse tree, where each <see cref="MTConnectDataItem.Id"/> becomes a tag's FullName.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Issues an Agent <c>/current</c> request and returns the latest observed value for every
|
||||
/// data item. Used both to prime the observation index at startup and to re-baseline after
|
||||
/// a detected sequence gap in <see cref="SampleAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Opens an Agent <c>/sample</c> long-poll stream starting at sequence <paramref name="from"/>
|
||||
/// and yields one <see cref="MTConnectStreamsResult"/> per multipart chunk the Agent sends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The only way this enumeration ends without throwing is <paramref name="ct"/> being
|
||||
/// cancelled.</b> Every other way a stream can stop — the connection dropping, the Agent
|
||||
/// writing its closing boundary, or the endpoint turning out not to stream at all —
|
||||
/// throws an <see cref="MTConnectStreamException"/> naming which. A consumer therefore
|
||||
/// never has to treat "the enumeration finished" as an ambiguous signal, and cannot
|
||||
/// silently lose its subscription to a data path that quietly stopped (#485). See
|
||||
/// <see cref="MTConnectStreamEndedException"/> (transient — reconnect) and
|
||||
/// <see cref="MTConnectStreamNotSupportedException"/> (configuration — reconnecting will
|
||||
/// reproduce it forever).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Advancing the cursor.</b> After each chunk, the caller's next expected sequence is
|
||||
/// that chunk's <see cref="MTConnectStreamsResult.NextSequence"/> — not the
|
||||
/// <paramref name="from"/> this call was opened with. Feed that running value to
|
||||
/// <see cref="IsSequenceGap"/> and, on reconnect, to a fresh <c>SampleAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="from">The sequence number to resume streaming from.</param>
|
||||
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
|
||||
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
|
||||
/// <paramref name="expectedFrom"/>, meaning observations between the two were evicted before
|
||||
/// the caller could read them and an incremental update would silently skip values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><paramref name="expectedFrom"/> is a running cursor, not the original
|
||||
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync"/> only
|
||||
/// for the <i>first</i> chunk; from the second chunk on it is the <b>previous</b>
|
||||
/// chunk's <see cref="MTConnectStreamsResult.NextSequence"/>. Comparing every chunk
|
||||
/// against the original <c>from</c> instead reports a gap on every chunk once the
|
||||
/// Agent's buffer has rolled past it — an endless <c>/current</c> re-baseline storm
|
||||
/// against a perfectly healthy stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Equality is not a gap: <c>firstSequence == expectedFrom</c> is the ordinary
|
||||
/// contiguous case where the Agent simply had nothing older to send.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This check alone does NOT cover every ring-buffer overflow.</b> It can only judge
|
||||
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
|
||||
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
|
||||
/// document served under <b>HTTP 200</b>, which this client surfaces as an
|
||||
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync"/>, never as a chunk.
|
||||
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
|
||||
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
|
||||
/// parse failure as a re-baseline trigger (re-prime from <see cref="CurrentAsync"/>,
|
||||
/// resume from that snapshot's <c>NextSequence</c>) just like a detected gap.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="expectedFrom">The sequence the caller expected this chunk to start at.</param>
|
||||
/// <param name="chunk">The chunk the Agent answered with.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> when observations were lost and the caller must re-baseline via
|
||||
/// <see cref="CurrentAsync"/> rather than trusting an incremental update.
|
||||
/// </returns>
|
||||
static bool IsSequenceGap(long expectedFrom, MTConnectStreamsResult chunk)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chunk);
|
||||
|
||||
return chunk.FirstSequence > expectedFrom;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The production <see cref="IMTConnectAgentClient"/> — a thin <see cref="HttpClient"/> wrapper
|
||||
/// over an MTConnect Agent's REST surface that hands every response body to a pure parser and
|
||||
/// returns only the neutral DTOs in <c>MTConnectDtos.cs</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
|
||||
/// its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
|
||||
/// so a throwaway instance is safe to construct — the universal discovery browser's
|
||||
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
|
||||
/// nothing until it is enumerated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two <see cref="HttpClient"/>s, and the split is deliberate.</b>
|
||||
/// <see cref="HttpClient.Timeout"/> is a per-instance, whole-response deadline, so a single
|
||||
/// client cannot serve both legs: the unary <c>/probe</c> and <c>/current</c> calls must be
|
||||
/// bounded by <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, while <c>/sample</c> is
|
||||
/// a long poll that is <i>supposed</i> to outlive that bound indefinitely. Raising the one
|
||||
/// timeout to suit the stream would un-bound the unary deadline — precisely what arch-review
|
||||
/// R2-01 says must never happen — so the streaming leg gets its own client with
|
||||
/// <see cref="Timeout.InfiniteTimeSpan"/> and is bounded differently instead (below). In
|
||||
/// production each client owns its own handler and connection pool; in tests both are built
|
||||
/// over one injected handler, which the caller owns.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every call is deadline-bounded, but not all by the same mechanism.</b> The unary legs
|
||||
/// run under a <see cref="CancellationTokenSource"/> linked to the caller's token and
|
||||
/// cancelled after <c>RequestTimeoutMs</c>. The streaming leg bounds its <i>header</i> phase
|
||||
/// the same way (a peer that accepts the connection and never answers must not wedge the
|
||||
/// pump), then hands the body to a <b>heartbeat watchdog</b>: MTConnect Agents emit a
|
||||
/// keep-alive chunk every <see cref="MTConnectDriverOptions.HeartbeatMs"/> precisely so a
|
||||
/// client can tell "quiet but alive" from "dead", so every body read is bounded by a
|
||||
/// two-missed-heartbeat window and a silent Agent surfaces as a
|
||||
/// <see cref="TimeoutException"/>. This is the exact shape of this repo's S7 frozen-peer
|
||||
/// production defect (arch-review R2-01) — an async read that ignores the socket deadline —
|
||||
/// and the watchdog is what closes it here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every operator-authorable duration/count is rejected at construction if non-positive.</b>
|
||||
/// A <c>0</c> must never come to mean "wait forever" (arch-review 01/S-6), and the three
|
||||
/// <c>/sample</c> query knobs additionally reach the wire: an Agent answers
|
||||
/// <c>heartbeat=0</c> with an <c>MTConnectError</c> under HTTP 200, which would otherwise
|
||||
/// surface as a parse failure that never names the offending config key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Disposal is safe against an in-flight enumeration.</b> Task 9 re-initializes the
|
||||
/// driver by disposing and rebuilding this client, which can happen while a pump is still
|
||||
/// enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
|
||||
/// an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
|
||||
/// caller is written to expect, every request runs under a token linked to an internal
|
||||
/// dispose token, so disposal surfaces as an ordinary
|
||||
/// <see cref="OperationCanceledException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
|
||||
/// status, an unparseable body, and <b>the end of a <c>/sample</c> stream</b> all throw. See
|
||||
/// <see cref="MTConnectStreamException"/> for why the last of those is an exception rather
|
||||
/// than a normal end of enumeration. <c>InitializeAsync</c> (Task 9) is what catches these
|
||||
/// and moves the driver to Faulted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly HttpClient _streamHttp;
|
||||
private readonly CancellationTokenSource _disposeCts = new();
|
||||
private readonly ILogger? _logger;
|
||||
private readonly Uri _probeUri;
|
||||
private readonly Uri _currentUri;
|
||||
private readonly string _sampleUriBase;
|
||||
private readonly string _sampleQuerySuffix;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
private readonly TimeSpan _streamIdleTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
|
||||
/// <param name="logger">
|
||||
/// Optional logger. Used only for conditions an operator needs to see but which are not
|
||||
/// failures — today, the degraded <c>/sample</c> framing mode.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException"><see cref="MTConnectDriverOptions.AgentUri"/> is missing or is not an absolute HTTP(S) URI.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// Any of <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>,
|
||||
/// <see cref="MTConnectDriverOptions.HeartbeatMs"/>,
|
||||
/// <see cref="MTConnectDriverOptions.SampleIntervalMs"/> or
|
||||
/// <see cref="MTConnectDriverOptions.SampleCount"/> is not positive.
|
||||
/// </exception>
|
||||
public MTConnectAgentClient(MTConnectDriverOptions options, ILogger? logger = null)
|
||||
: this(options, handler: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions, ILogger)"/>, but
|
||||
/// sends through the supplied handler so the request/response round trip can be exercised
|
||||
/// with no socket. The caller retains ownership of <paramref name="handler"/>.
|
||||
/// </summary>
|
||||
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler, ILogger? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
RequirePositive(
|
||||
options.RequestTimeoutMs,
|
||||
nameof(MTConnectDriverOptions.RequestTimeoutMs),
|
||||
"a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely");
|
||||
RequirePositive(
|
||||
options.HeartbeatMs,
|
||||
nameof(MTConnectDriverOptions.HeartbeatMs),
|
||||
"it is sent to the Agent as 'heartbeat', which an Agent rejects with an MTConnectError under HTTP 200, and it is what bounds the /sample watchdog — a quiet connection would otherwise be indistinguishable from a dead one");
|
||||
RequirePositive(
|
||||
options.SampleIntervalMs,
|
||||
nameof(MTConnectDriverOptions.SampleIntervalMs),
|
||||
"it is sent to the Agent as 'interval'; zero would spin the streaming connection into a busy-loop");
|
||||
RequirePositive(
|
||||
options.SampleCount,
|
||||
nameof(MTConnectDriverOptions.SampleCount),
|
||||
"it is sent to the Agent as 'count'; a non-positive count asks the Agent for no observations");
|
||||
|
||||
_logger = logger;
|
||||
_requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
|
||||
|
||||
// Two missed heartbeats plus one request timeout of network slack. Deriving the window from
|
||||
// HeartbeatMs is what makes it a liveness check rather than a throughput assumption: a busy
|
||||
// Agent and an idle one both emit at least one chunk per heartbeat.
|
||||
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
|
||||
|
||||
_probeUri = BuildRequestUri(options, "probe");
|
||||
_currentUri = BuildRequestUri(options, "current");
|
||||
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
|
||||
_sampleQuerySuffix = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");
|
||||
|
||||
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
|
||||
_http.Timeout = _requestTimeout;
|
||||
|
||||
_streamHttp = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
|
||||
|
||||
// See the class remarks: a long poll must outlive the unary deadline by design, and the
|
||||
// heartbeat watchdog — not this property — is what keeps it bounded.
|
||||
_streamHttp.Timeout = Timeout.InfiniteTimeSpan;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
|
||||
// call, so the parse below reads from memory — no network read can outlive the deadline.
|
||||
using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return MTConnectProbeParser.Parse(body);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return MTConnectStreamsParser.Parse(body);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var uri = BuildSampleUri(from);
|
||||
|
||||
// Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
|
||||
// surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
var live = lifetime.Token;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
|
||||
// ResponseHeadersRead, not the default ResponseContentRead: buffering a
|
||||
// multipart/x-mixed-replace body that is designed never to end would hang here forever.
|
||||
using var response = await SendStreamRequestAsync(request, uri, lifetime).ConfigureAwait(false);
|
||||
|
||||
await using var body = await response.Content.ReadAsStreamAsync(live).ConfigureAwait(false);
|
||||
|
||||
var boundary = ResolveMultipartBoundary(response, uri);
|
||||
if (boundary is null)
|
||||
{
|
||||
// Not a multipart stream: the Agent answered with a single document, which means it
|
||||
// ignored the streaming request entirely. Parse it first — an MTConnectError arrives in
|
||||
// exactly this shape (text/xml under HTTP 200) and the Agent's own error text is far
|
||||
// more useful than ours — then report the misconfiguration. The parsed snapshot is
|
||||
// deliberately NOT yielded: one document followed by a healthy-looking finished stream
|
||||
// is how a dead data path disguises itself as a working one (#485).
|
||||
var single = await ReadWholeBodyAsync(body, uri, lifetime).ConfigureAwait(false);
|
||||
_ = MTConnectStreamsParser.Parse(single);
|
||||
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
}
|
||||
|
||||
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
|
||||
var delivered = 0L;
|
||||
while (true)
|
||||
{
|
||||
var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
|
||||
if (part is null)
|
||||
{
|
||||
// The caller cancelling is the ONE contracted way out; anything else is reported.
|
||||
live.ThrowIfCancellationRequested();
|
||||
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
|
||||
}
|
||||
|
||||
// A frame carrying nothing but whitespace is transport filler, not a document. The
|
||||
// Agent's real keep-alive is a well-formed observation-free MTConnectStreams document,
|
||||
// and that one IS yielded — it advances the sequence, so swallowing it would strand the
|
||||
// caller's `from` behind the Agent's buffer.
|
||||
if (string.IsNullOrWhiteSpace(part))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
delivered++;
|
||||
|
||||
yield return MTConnectStreamsParser.Parse(part);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
|
||||
// than a disposed handler.
|
||||
_disposeCts.Cancel();
|
||||
_http.Dispose();
|
||||
_streamHttp.Dispose();
|
||||
_disposeCts.Dispose();
|
||||
}
|
||||
|
||||
private static void RequirePositive(int value, string optionName, string because)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(MTConnectDriverOptions),
|
||||
value,
|
||||
$"{optionName} must be positive; {because}.");
|
||||
}
|
||||
}
|
||||
|
||||
private Uri BuildSampleUri(long from) =>
|
||||
new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
|
||||
UriKind.Absolute);
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
// Neither the caller nor disposal cancelled, so this is our own deadline (or
|
||||
// HttpClient.Timeout) firing. Reported as a TimeoutException because a bare "A task was
|
||||
// canceled" tells an operator nothing about which Agent stopped answering.
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues the <c>/sample</c> request and returns as soon as the response headers land. Only
|
||||
/// this phase is bounded by the unary deadline; the body is bounded by the watchdog.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendStreamRequestAsync(
|
||||
HttpRequestMessage request, Uri uri, CancellationTokenSource lifetime)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _streamHttp
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>Reads a non-multipart streaming body whole, under the unary deadline.</summary>
|
||||
private async Task<string> ReadWholeBodyAsync(Stream body, Uri uri, CancellationTokenSource lifetime)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
using var reader = new StreamReader(body, Encoding.UTF8);
|
||||
try
|
||||
{
|
||||
return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(uri);
|
||||
}
|
||||
}
|
||||
|
||||
private TimeoutException TimedOut(Uri uri) =>
|
||||
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
|
||||
/// <summary>
|
||||
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
|
||||
/// Agent answered with a single (non-multipart) document.
|
||||
/// </summary>
|
||||
/// <exception cref="MTConnectStreamNotSupportedException">
|
||||
/// The response claims to be <c>multipart/*</c> but carries no <c>boundary</c> parameter, so
|
||||
/// there is nothing to frame it by. Failed fast and named, because the alternative — falling
|
||||
/// through to whole-body reading of a body that never ends — surfaces much later as a bare
|
||||
/// watchdog <see cref="TimeoutException"/> that points an operator at the network instead of
|
||||
/// at the malformed header.
|
||||
/// </exception>
|
||||
private static string? ResolveMultipartBoundary(HttpResponseMessage response, Uri uri)
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType;
|
||||
if (contentType?.MediaType is null ||
|
||||
!contentType.MediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var boundary = contentType.Parameters
|
||||
.FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
|
||||
.Trim('"');
|
||||
|
||||
if (string.IsNullOrEmpty(boundary))
|
||||
{
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
}
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
|
||||
{
|
||||
var agentUri = options.AgentUri?.Trim();
|
||||
if (string.IsNullOrEmpty(agentUri))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} is required (e.g. 'http://agent:5000').", nameof(options));
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
|
||||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
|
||||
}
|
||||
|
||||
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
var deviceName = options.DeviceName?.Trim();
|
||||
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
|
||||
|
||||
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frames a <c>multipart/x-mixed-replace</c> body into individual part payloads, reading
|
||||
/// incrementally and never buffering more than the part currently in flight.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled.</b> ASP.NET Core's <c>MultipartReader</c> is not in this
|
||||
/// dependency set, and TrakHound's <c>MTConnectHttpClientStream</c> — the one candidate
|
||||
/// the plan left open — takes a URL and dials its own socket, so it can neither be fed an
|
||||
/// existing response stream nor be exercised without a listener. See the Task 0
|
||||
/// correction in the plan.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two framing paths, and the first one matters for latency.</b> When the part carries
|
||||
/// a <c>Content-length</c> header — every Agent in the wild writes one — the body is read
|
||||
/// by length and the chunk is emitted the instant it is complete. Without it there is no
|
||||
/// way to know a part has ended except by seeing the <i>next</i> boundary, so that path
|
||||
/// necessarily emits each chunk one chunk late: with the default 10 s heartbeat a value
|
||||
/// can be up to a heartbeat stale on a driver built for low-latency subscribe. It is a
|
||||
/// correctness fallback, not the expected path, and it logs a one-shot warning when it
|
||||
/// engages so the degradation is visible rather than merely slow. (It cannot strand the
|
||||
/// final part indefinitely — a clean end of stream returns the buffered tail — though a
|
||||
/// watchdog timeout on a stalled Agent does discard it.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every read is watchdog-bounded.</b> A silent peer faults the stream rather than
|
||||
/// parking a task forever (arch-review R2-01), and the buffer is capped so a hostile or
|
||||
/// malfunctioning Agent cannot grow it without limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class MultipartStreamReader(
|
||||
Stream stream, string boundary, TimeSpan idleTimeout, Uri uri, ILogger? logger)
|
||||
{
|
||||
private const int MaxBufferBytes = 32 * 1024 * 1024;
|
||||
|
||||
private static readonly byte[] CrLfCrLf = "\r\n\r\n"u8.ToArray();
|
||||
private static readonly byte[] LfLf = "\n\n"u8.ToArray();
|
||||
|
||||
private readonly byte[] _delimiter = Encoding.ASCII.GetBytes("--" + boundary);
|
||||
private byte[] _buffer = new byte[16 * 1024];
|
||||
private int _length;
|
||||
private bool _finished;
|
||||
private bool _warnedNoContentLength;
|
||||
|
||||
/// <summary>How the stream ended. Only meaningful once a read has returned <c>null</c>.</summary>
|
||||
public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the next part's payload, or <c>null</c> once the closing delimiter is seen or
|
||||
/// the Agent closes the connection — see <see cref="EndReason"/> for which.
|
||||
/// </summary>
|
||||
public async Task<string?> ReadNextPartAsync(CancellationToken ct)
|
||||
{
|
||||
if (_finished)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
|
||||
!await EnsureAsync(2, ct).ConfigureAwait(false))
|
||||
{
|
||||
return Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
}
|
||||
|
||||
// "--boundary--" closes the stream.
|
||||
if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
|
||||
{
|
||||
return Finish(MTConnectStreamEndReason.ClosingBoundary);
|
||||
}
|
||||
|
||||
var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
|
||||
if (headerEnd < 0)
|
||||
{
|
||||
return Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
}
|
||||
|
||||
var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
|
||||
Consume(headerEnd);
|
||||
|
||||
byte[]? body;
|
||||
if (ParseContentLength(headers) is { } declared)
|
||||
{
|
||||
body = await ReadByLengthAsync(declared, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
WarnOnceAboutMissingContentLength();
|
||||
body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return body is null ? null : Encoding.UTF8.GetString(body);
|
||||
}
|
||||
|
||||
private void WarnOnceAboutMissingContentLength()
|
||||
{
|
||||
if (_warnedNoContentLength)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_warnedNoContentLength = true;
|
||||
|
||||
logger?.LogWarning(
|
||||
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
|
||||
uri);
|
||||
}
|
||||
|
||||
private string? Finish(MTConnectStreamEndReason reason)
|
||||
{
|
||||
_finished = true;
|
||||
EndReason = reason;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadByLengthAsync(int declared, CancellationToken ct)
|
||||
{
|
||||
if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
|
||||
{
|
||||
Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var body = _buffer[..declared];
|
||||
Consume(declared);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadToNextDelimiterAsync(CancellationToken ct)
|
||||
{
|
||||
var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
|
||||
if (next < 0)
|
||||
{
|
||||
// EOF with no closing delimiter: whatever is buffered is the last part. Returned
|
||||
// rather than dropped, so a clean end of stream cannot strand a chunk.
|
||||
var tail = _buffer[.._length];
|
||||
_length = 0;
|
||||
Finish(MTConnectStreamEndReason.ConnectionClosed);
|
||||
|
||||
return tail;
|
||||
}
|
||||
|
||||
var body = _buffer[..TrimTrailingLineBreak(next)];
|
||||
|
||||
// Leave the delimiter itself in place; the next call's skip consumes it.
|
||||
Consume(next);
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Strips the single line break that terminates a part body before its delimiter.</summary>
|
||||
private int TrimTrailingLineBreak(int end)
|
||||
{
|
||||
if (end >= 2 && _buffer[end - 2] == (byte)'\r' && _buffer[end - 1] == (byte)'\n')
|
||||
{
|
||||
return end - 2;
|
||||
}
|
||||
|
||||
return end >= 1 && _buffer[end - 1] == (byte)'\n' ? end - 1 : end;
|
||||
}
|
||||
|
||||
/// <summary>Advances past the next boundary delimiter; <c>false</c> at end of stream.</summary>
|
||||
private async Task<bool> SkipThroughDelimiterAsync(CancellationToken ct)
|
||||
{
|
||||
var index = await FindDelimiterAsync(ct).ConfigureAwait(false);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Consume(index + _delimiter.Length);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> FindDelimiterAsync(CancellationToken ct)
|
||||
{
|
||||
var searchedTo = 0;
|
||||
while (true)
|
||||
{
|
||||
// Restart the scan far enough back that a delimiter split across two reads is still
|
||||
// found — without the overlap, a boundary straddling a packet boundary is invisible.
|
||||
var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
|
||||
if (index >= 0)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
|
||||
searchedTo = _length;
|
||||
if (!await FillAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the end of the part's header block — the first blank line after the delimiter.
|
||||
/// A part with no headers at all is legal, in which case the blank line sits at offset 0.
|
||||
/// </summary>
|
||||
private async Task<int> FindHeaderEndAsync(CancellationToken ct)
|
||||
{
|
||||
var searchedTo = 0;
|
||||
while (true)
|
||||
{
|
||||
var crlf = IndexOf(CrLfCrLf, Math.Max(0, searchedTo - 3));
|
||||
var lf = IndexOf(LfLf, Math.Max(0, searchedTo - 1));
|
||||
|
||||
if (crlf >= 0 && (lf < 0 || crlf <= lf))
|
||||
{
|
||||
return crlf + CrLfCrLf.Length;
|
||||
}
|
||||
|
||||
if (lf >= 0)
|
||||
{
|
||||
return lf + LfLf.Length;
|
||||
}
|
||||
|
||||
searchedTo = _length;
|
||||
if (!await FillAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ParseContentLength(string headers)
|
||||
{
|
||||
foreach (var line in headers.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (!trimmed.StartsWith("content-length:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var raw = trimmed["content-length:".Length..].Trim();
|
||||
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) &&
|
||||
value >= 0
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> EnsureAsync(int count, CancellationToken ct)
|
||||
{
|
||||
while (_length < count)
|
||||
{
|
||||
if (!await FillAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads at least one more byte, bounded by the heartbeat watchdog. Returns <c>false</c>
|
||||
/// at end of stream and throws <see cref="TimeoutException"/> when the Agent goes silent.
|
||||
/// </summary>
|
||||
private async Task<bool> FillAsync(CancellationToken ct)
|
||||
{
|
||||
if (_length == _buffer.Length)
|
||||
{
|
||||
if (_buffer.Length >= MaxBufferBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
}
|
||||
|
||||
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
|
||||
}
|
||||
|
||||
using var watchdog = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
watchdog.CancelAfter(idleTimeout);
|
||||
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = await stream.ReadAsync(_buffer.AsMemory(_length), watchdog.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
}
|
||||
|
||||
if (read <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_length += read;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private int IndexOf(byte[] pattern, int start)
|
||||
{
|
||||
if (start >= _length)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var index = _buffer.AsSpan(start, _length - start).IndexOf(pattern.AsSpan());
|
||||
|
||||
return index < 0 ? -1 : index + start;
|
||||
}
|
||||
|
||||
private void Consume(int count)
|
||||
{
|
||||
Buffer.BlockCopy(_buffer, count, _buffer, 0, _length - count);
|
||||
_length -= count;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="MTConnectDriver"/>. The Host registers it
|
||||
/// once at startup; the bootstrapper then materialises MTConnect <c>DriverInstance</c> rows from
|
||||
/// the deployed configuration into live driver instances. Mirrors
|
||||
/// <c>ModbusDriverFactoryExtensions</c> / <c>FocasDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>There is exactly one parser for the config document.</b> This factory does not carry a
|
||||
/// config DTO of its own — it delegates to <see cref="MTConnectDriver.ParseOptions"/>, which
|
||||
/// the driver itself must own because the runtime delivers a config <i>change</i> to a live
|
||||
/// instance (<c>DriverInstanceActor</c> assigns its driver once and calls
|
||||
/// <c>ReinitializeAsync(newJson)</c> thereafter). A second DTO here would drift from that
|
||||
/// one silently, and the drift would first show up at deploy time as an operator's edit
|
||||
/// being applied differently by the two paths — or not at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Construction opens nothing.</b> The Wave-0 universal discovery browser builds a
|
||||
/// throwaway instance through this factory purely to read
|
||||
/// <c>ITagDiscovery.SupportsOnlineDiscovery</c>, and never initializes it — so a factory
|
||||
/// that dialled the Agent (or that pre-built an <see cref="IMTConnectAgentClient"/>) would
|
||||
/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The
|
||||
/// agent-client factory is therefore left null: <see cref="MTConnectDriver"/> builds the
|
||||
/// production client inside <c>InitializeAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
|
||||
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
|
||||
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
|
||||
/// <see cref="IRediscoverable"/>) by pattern-matching the object
|
||||
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
|
||||
/// narrowed return is how a capability ends up implemented but never dispatched. There is
|
||||
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MTConnectDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>DriverInstance.DriverType</c> value this factory answers to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Aliased to <see cref="DriverTypeNames.MTConnect"/>, not a literal.</b> The registry key,
|
||||
/// the instance's self-reported <see cref="MTConnectDriver.DriverType"/>, the probe's
|
||||
/// <c>DriverType</c>, and the constant every dispatch map keys off are therefore the same
|
||||
/// symbol — the repo-wide fix for the historical <c>TwinCat</c>/<c>Focas</c> drift, where a
|
||||
/// driver's own spelling silently diverged from the constant and dispatch maps missed it.
|
||||
/// Retained as a named const (rather than deleted in favour of the constant) so the existing
|
||||
/// factory callers and the sibling <c>*DriverFactoryExtensions.DriverTypeName</c> convention
|
||||
/// keep working; it is now an alias with no independent value.
|
||||
/// </remarks>
|
||||
public const string DriverTypeName = DriverTypeNames.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Register the MTConnect factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct
|
||||
/// an <see cref="ILogger{TCategoryName}"/> per driver instance — without it the driver runs
|
||||
/// with the null logger (tests and standalone callers stay unchanged).
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
}
|
||||
|
||||
/// <summary>Public for the Server-side bootstrapper + test consumers.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
|
||||
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
|
||||
public static MTConnectDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
/// <summary>Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <returns>The constructed <see cref="MTConnectDriver"/> instance.</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is null or blank.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The config document is unparseable, deserialises to null, omits the required
|
||||
/// <c>agentUri</c>, or carries an unauthorable enum/tag value. Throwing is correct at this
|
||||
/// seam: a deployment carrying such a row must fail rather than register a driver pointed at
|
||||
/// nothing, and the discovery browser's <c>CanBrowse</c> catches factory exceptions so a
|
||||
/// half-authored config merely disables the address picker.
|
||||
/// </exception>
|
||||
public static MTConnectDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
// The single config authority — see the class remarks for why this is not a second DTO.
|
||||
// Note the asymmetry with the driver's own lifecycle path: an empty ("{}" / "[]") document
|
||||
// there means "no config supplied, keep the constructor options", but this factory HAS no
|
||||
// constructor options to keep, so an empty document is simply a config with no agentUri and
|
||||
// must fault.
|
||||
var options = MTConnectDriver.ParseOptions(driverInstanceId, driverConfigJson);
|
||||
|
||||
// Named arguments deliberately: the ctor's trailing parameters are all optional, so a
|
||||
// future insertion could silently re-bind a positional argument to the wrong one.
|
||||
return new MTConnectDriver(
|
||||
options,
|
||||
driverInstanceId,
|
||||
agentClientFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<MTConnectDriver>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
|
||||
/// single <c>GET {AgentUri}/probe</c> under the supplied deadline and reports success/failure
|
||||
/// with latency — it does not construct or initialize an <see cref="MTConnectDriver"/> instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Deliberately does NOT delegate to <see cref="MTConnectDriver.ParseOptions"/>.</b> That
|
||||
/// method builds the full driver options document, including every authored
|
||||
/// <c>MTConnectTagDefinition</c> (<c>BuildTag</c> can throw on a malformed tag entry that has
|
||||
/// nothing to do with reachability). A probe only needs <c>agentUri</c>, so this type parses
|
||||
/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
|
||||
/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
|
||||
/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
|
||||
/// (<c>MTConnectDriverFactoryExtensions</c>), which is authored separately (Task 15).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Accepted gap: reachable ≠ deployable.</b> Because only <c>agentUri</c> is read, a
|
||||
/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive
|
||||
/// <c>requestTimeoutMs</c> or a malformed tag entry the factory's full parse would reject.
|
||||
/// This is the direct consequence of the independent-parsing decision above, judged
|
||||
/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact
|
||||
/// config deploy".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately DOES reuse <see cref="MTConnectAgentClient"/> and
|
||||
/// <see cref="MTConnectProbeParser"/> for the actual round trip.</b> Hand-rolling the HTTP
|
||||
/// call would have to re-derive the exact behaviour the client already provides: per-call
|
||||
/// <see cref="HttpClient.Timeout"/> bounding, a linked-CTS deadline, and — most importantly —
|
||||
/// correct handling of an Agent's <c>MTConnectError</c> document served under HTTP 200 (so
|
||||
/// <c>EnsureSuccessStatusCode</c> never fires). <see cref="MTConnectProbeParser"/> already
|
||||
/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand
|
||||
/// here would either duplicate that logic or under-validate (accepting any 200 response as
|
||||
/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit
|
||||
/// before, #485). The one-off cost of a full probe-document parse is negligible next to that
|
||||
/// risk.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Never throws</b> (the <see cref="IDriverProbe"/> contract): every failure — unparseable
|
||||
/// JSON, missing/malformed <c>agentUri</c>, DNS/TCP failure, a non-2xx response, a 200 body
|
||||
/// that is not a valid <c>MTConnectDevices</c> document, and timeout — becomes
|
||||
/// <c>Ok = false</c> with a message. Genuine caller cancellation is likewise converted rather
|
||||
/// than left to propagate, matching <c>ModbusDriverProbe</c>'s posture (its linked-CTS catch
|
||||
/// does not distinguish the caller's token from its own deadline).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Non-positive <c>timeout</c> (arch-review 01/S-6).</b> A <c>0</c> or negative
|
||||
/// timeout does NOT mean "wait forever" — it is replaced with <see cref="FallbackTimeout"/>
|
||||
/// (5s, matching <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>'s own default) so an
|
||||
/// operator-authorable field can never brick the probe UI.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Secrets discipline.</b> <c>AgentUri</c> may carry userinfo credentials
|
||||
/// (<c>http://user:pass@host/</c>). Every message this type constructs from the URI uses a
|
||||
/// redacted display form (<see cref="RedactedDisplay"/>) built ONLY from
|
||||
/// <see cref="Uri.Scheme"/>/<see cref="Uri.Host"/>/<see cref="Uri.Port"/>/<see cref="Uri.AbsolutePath"/>
|
||||
/// — never <see cref="Uri.UserInfo"/> and never the raw config string — so a password can
|
||||
/// never reach the AdminUI via the returned <see cref="DriverProbeResult.Message"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectDriverProbe : IDriverProbe
|
||||
{
|
||||
/// <summary>Replaces a non-positive caller <c>timeout</c> — see the type remarks.</summary>
|
||||
private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Caps an excessive caller <c>timeout</c>. <c>CancellationTokenSource.CancelAfter(TimeSpan)</c>'s
|
||||
/// legal range tops out near ~49.7 days (<see cref="uint.MaxValue"/> ms minus one); an
|
||||
/// uncapped pathological value (e.g. a caller passing <c>TimeSpan.FromDays(1000)</c>) throws
|
||||
/// <see cref="ArgumentOutOfRangeException"/> straight out of
|
||||
/// <c>CancelAfter</c>, which would violate the "Never throws" contract just as surely as a
|
||||
/// non-positive timeout meaning "wait forever" would (arch-review 01/S-6 — this is the same
|
||||
/// defect class, the high end rather than the low end). 10 minutes is generous for a
|
||||
/// reachability probe and leaves an enormous margin under the legal ceiling.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Test seam: routes the reused <see cref="MTConnectAgentClient"/> through a stub handler so response-shape cases need no socket.</summary>
|
||||
private readonly HttpMessageHandler? _handler;
|
||||
|
||||
/// <summary>Creates the probe used in production (real sockets).</summary>
|
||||
public MTConnectDriverProbe()
|
||||
: this(handler: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam constructor — see <see cref="_handler"/>.</summary>
|
||||
internal MTConnectDriverProbe(HttpMessageHandler? handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Sourced from the <see cref="DriverTypeNames"/> constant, not a literal — the probe is
|
||||
/// looked up by <c>AdminOperationsActor</c> under this key, so a drift from the constant
|
||||
/// silently disables the Test Connect button rather than failing anything at build time.
|
||||
/// </remarks>
|
||||
public string DriverType => DriverTypeNames.MTConnect;
|
||||
|
||||
/// <summary>Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.</summary>
|
||||
private sealed class MTConnectProbeConfigDto
|
||||
{
|
||||
public string? AgentUri { get; set; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
MTConnectProbeConfigDto? dto;
|
||||
try
|
||||
{
|
||||
dto = JsonSerializer.Deserialize<MTConnectProbeConfigDto>(configJson, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, $"Config JSON is invalid: {ex.Message}", null);
|
||||
}
|
||||
|
||||
if (dto is null)
|
||||
{
|
||||
return new(false, "Config JSON deserialized to null.", null);
|
||||
}
|
||||
|
||||
var agentUri = dto.AgentUri?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(agentUri))
|
||||
{
|
||||
return new(false, "Config has no agentUri to probe.", null);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
|
||||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return new(false, "Config's agentUri is not an absolute http(s) URI.", null);
|
||||
}
|
||||
|
||||
var safeUri = RedactedDisplay(parsed);
|
||||
|
||||
// arch-review 01/S-6: a non-positive timeout must never mean "wait forever" (low end), and a
|
||||
// pathologically large one must never overrun CancelAfter's legal range (high end) — see
|
||||
// MaxTimeout.
|
||||
var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
|
||||
if (effectiveTimeout > MaxTimeout)
|
||||
{
|
||||
effectiveTimeout = MaxTimeout;
|
||||
}
|
||||
|
||||
var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(effectiveTimeout);
|
||||
|
||||
var options = new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = agentUri,
|
||||
RequestTimeoutMs = requestTimeoutMs,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new MTConnectAgentClient(options, _handler);
|
||||
_ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
|
||||
return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Covers both the caller's token and our own CancelAfter deadline firing — see the type
|
||||
// remarks on why cancellation is never allowed to propagate.
|
||||
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// MTConnectAgentClient's own request-deadline signal.
|
||||
return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// Connection failure or non-2xx status. HttpRequestException messages describe the
|
||||
// socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe.
|
||||
return new(false, $"Request to {safeUri} failed: {ex.Message}", null);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
// MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including
|
||||
// an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or
|
||||
// a document with no devices. Never includes the request URI.
|
||||
return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this
|
||||
// method's own Uri.TryCreate above passed but the client's stricter check disagreed.
|
||||
// Never forward the exception's message verbatim — it can echo the raw (possibly
|
||||
// credentialed) config string.
|
||||
return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Last-resort catch for an exception type none of the above enumerate. Deliberately does
|
||||
// NOT forward ex.Message: unlike the typed catches above (each individually audited for
|
||||
// whether its message can carry the raw AgentUri/userinfo), an unenumerated type has no
|
||||
// such audit, so ex.Message is an open door for a credential leak. safeUri + the
|
||||
// exception's TYPE name is enough for an operator to act on without that risk.
|
||||
return new(false, $"Probe against {safeUri} failed unexpectedly ({ex.GetType().Name}).", null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a display form of <paramref name="uri"/> for use in returned messages, composed
|
||||
/// ONLY from scheme/host/port/path — never <see cref="Uri.UserInfo"/> — so a credentialed
|
||||
/// <c>AgentUri</c> can never leak a password into the AdminUI.
|
||||
/// </summary>
|
||||
private static string RedactedDisplay(Uri uri)
|
||||
{
|
||||
var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
|
||||
return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Result of an Agent <c>/probe</c> request: the full device hierarchy the Agent exposes.
|
||||
/// Produced by <see cref="IMTConnectAgentClient.ProbeAsync"/>. This is the neutral shape both
|
||||
/// the TrakHound-backed implementation and a hand-rolled XML fallback must produce — nothing
|
||||
/// downstream of <see cref="IMTConnectAgentClient"/> ever sees a TrakHound type or an
|
||||
/// <c>XElement</c>.
|
||||
/// </summary>
|
||||
/// <param name="Devices">Every device the Agent's probe response declares.</param>
|
||||
public sealed record MTConnectProbeModel(IReadOnlyList<MTConnectDevice> Devices);
|
||||
|
||||
/// <summary>
|
||||
/// Common shape shared by <see cref="MTConnectDevice"/> and <see cref="MTConnectComponent"/>:
|
||||
/// each may directly own data items and/or nest further components, to arbitrary depth. Backs
|
||||
/// the <see cref="MTConnectComponentTreeExtensions.AllDataItems"/> recursive walk.
|
||||
/// </summary>
|
||||
public interface IMTConnectComponentContainer
|
||||
{
|
||||
/// <summary>Components nested directly under this device/component (may be empty).</summary>
|
||||
IReadOnlyList<MTConnectComponent> Components { get; }
|
||||
|
||||
/// <summary>Data items declared directly on this device/component (may be empty).</summary>
|
||||
IReadOnlyList<MTConnectDataItem> DataItems { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One MTConnect device from the Agent's probe response — the root of a nested
|
||||
/// component/data-item tree.
|
||||
/// </summary>
|
||||
/// <param name="Id">The device's <c>id</c> attribute.</param>
|
||||
/// <param name="Name">The device's <c>name</c> attribute, when the Agent supplies one.</param>
|
||||
/// <param name="Components">Child components declared directly under this device.</param>
|
||||
/// <param name="DataItems">Data items declared directly on this device (outside any component).</param>
|
||||
public sealed record MTConnectDevice(
|
||||
string Id,
|
||||
string? Name,
|
||||
IReadOnlyList<MTConnectComponent> Components,
|
||||
IReadOnlyList<MTConnectDataItem> DataItems) : IMTConnectComponentContainer;
|
||||
|
||||
/// <summary>
|
||||
/// One MTConnect component (e.g. <c>Axes</c>, <c>Controller</c>, <c>Path</c>) from the Agent's
|
||||
/// probe response. Components nest to arbitrary depth — a component may itself contain further
|
||||
/// child components as well as data items.
|
||||
/// </summary>
|
||||
/// <param name="Id">The component's <c>id</c> attribute.</param>
|
||||
/// <param name="Name">The component's <c>name</c> attribute, when the Agent supplies one.</param>
|
||||
/// <param name="Components">Child components nested directly under this component.</param>
|
||||
/// <param name="DataItems">Data items declared directly on this component.</param>
|
||||
public sealed record MTConnectComponent(
|
||||
string Id,
|
||||
string? Name,
|
||||
IReadOnlyList<MTConnectComponent> Components,
|
||||
IReadOnlyList<MTConnectDataItem> DataItems) : IMTConnectComponentContainer;
|
||||
|
||||
/// <summary>
|
||||
/// One MTConnect DataItem declaration from the Agent's probe response. This is pure metadata —
|
||||
/// it carries no observed value; values arrive separately via
|
||||
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync"/>
|
||||
/// and are correlated back to a data item by <see cref="Id"/>.
|
||||
/// </summary>
|
||||
/// <param name="Id">
|
||||
/// The DataItem's <c>id</c> attribute — globally unique within the Agent's probe response and
|
||||
/// the correlation key used against <see cref="MTConnectObservation.DataItemId"/>.
|
||||
/// </param>
|
||||
/// <param name="Name">The DataItem's <c>name</c> attribute, when the Agent supplies one.</param>
|
||||
/// <param name="Category">The DataItem's <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.</param>
|
||||
/// <param name="Type">The DataItem's <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).</param>
|
||||
/// <param name="SubType">The DataItem's <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>), when present.</param>
|
||||
/// <param name="Units">The DataItem's <c>units</c> attribute (e.g. <c>MILLIMETER</c>, <c>REVOLUTION/MINUTE</c>), when present.</param>
|
||||
/// <param name="Representation">
|
||||
/// The DataItem's <c>representation</c> attribute (e.g. <c>TIME_SERIES</c>, <c>DISCRETE</c>);
|
||||
/// absent means the MTConnect default (<c>VALUE</c>).
|
||||
/// </param>
|
||||
/// <param name="SampleCount">
|
||||
/// The DataItem's <c>sampleCount</c> attribute — element count for a <c>TIME_SERIES</c>
|
||||
/// representation. Present only on data items that carry one.
|
||||
/// </param>
|
||||
public sealed record MTConnectDataItem(
|
||||
string Id,
|
||||
string? Name,
|
||||
string Category,
|
||||
string Type,
|
||||
string? SubType,
|
||||
string? Units,
|
||||
string? Representation,
|
||||
int? SampleCount);
|
||||
|
||||
/// <summary>
|
||||
/// Result of an Agent <c>/current</c> request, or of a single multipart chunk from a
|
||||
/// <c>/sample</c> stream. Produced by <see cref="IMTConnectAgentClient.CurrentAsync"/> and
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="InstanceId">
|
||||
/// The Agent's current instance id (its MTConnectStreams header <c>instanceId</c>). Changes
|
||||
/// whenever the Agent restarts or its underlying device model changes; the driver watches this
|
||||
/// for change to raise rediscovery rather than trusting a stale observation snapshot.
|
||||
/// </param>
|
||||
/// <param name="NextSequence">
|
||||
/// The header <c>nextSequence</c> — the sequence number to resume a subsequent
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/> call <c>from</c>.
|
||||
/// </param>
|
||||
/// <param name="FirstSequence">
|
||||
/// The header <c>firstSequence</c> — the oldest sequence number still held in the Agent's
|
||||
/// circular buffer. Load-bearing for sequence-gap detection: a caller that requested
|
||||
/// <c>from</c> a sequence older than this chunk's <see cref="FirstSequence"/> has fallen out of
|
||||
/// the Agent's buffer and must re-baseline via <see cref="IMTConnectAgentClient.CurrentAsync"/>.
|
||||
/// </param>
|
||||
/// <param name="Observations">
|
||||
/// The observations carried in this snapshot/chunk, in Agent-supplied order. Never <c>null</c>;
|
||||
/// empty when the chunk carries no new observations (e.g. a heartbeat).
|
||||
/// </param>
|
||||
public sealed record MTConnectStreamsResult(
|
||||
long InstanceId,
|
||||
long NextSequence,
|
||||
long FirstSequence,
|
||||
IReadOnlyList<MTConnectObservation> Observations);
|
||||
|
||||
/// <summary>
|
||||
/// One observed value for a single DataItem, as reported in a <c>/current</c> snapshot or a
|
||||
/// <c>/sample</c> stream chunk. Deliberately dumb: <see cref="Value"/> is carried as the raw
|
||||
/// string the Agent reported (including the literal <c>UNAVAILABLE</c>) with no interpretation
|
||||
/// applied here — coercing <c>UNAVAILABLE</c> to a bad-quality status and converting the string
|
||||
/// to the tag's <c>DriverDataType</c> are the observation index's job, not this layer's.
|
||||
/// </summary>
|
||||
/// <param name="DataItemId">
|
||||
/// The reporting DataItem's <c>id</c> — correlates back to <see cref="MTConnectDataItem.Id"/>
|
||||
/// from the probe model.
|
||||
/// </param>
|
||||
/// <param name="Value">
|
||||
/// The observed value exactly as the Agent reported it, or the literal string
|
||||
/// <c>"UNAVAILABLE"</c> when the Agent has no current value for the data item. Never
|
||||
/// pre-parsed into a nullable or an enum at this layer.
|
||||
/// </param>
|
||||
/// <param name="TimestampUtc">
|
||||
/// The observation's Agent-reported timestamp, normalized to UTC (<see cref="DateTime.Kind"/>
|
||||
/// is always <see cref="DateTimeKind.Utc"/>). Becomes the OPC UA variable's SourceTimestamp.
|
||||
/// </param>
|
||||
/// <param name="IsStructured">
|
||||
/// <c>true</c> when the Agent carried this observation's real content in <b>child elements</b>
|
||||
/// rather than as text — an MTConnect 2.0 <c>DATA_SET</c> / <c>TABLE</c> observation, whose
|
||||
/// content is a list of <c><Entry key="…"></c> elements.
|
||||
/// <para>
|
||||
/// <b>Why the flag exists, and why only the parser can set it.</b> <see cref="Value"/> is
|
||||
/// the element's concatenated descendant text, so a two-entry data set reads as the single
|
||||
/// token <c>"12"</c> — keys discarded, values run together. The observation index cannot
|
||||
/// detect that after the fact: neither this record nor the tag definition carries the
|
||||
/// DataItem's <c>representation</c>, and no value-shape heuristic can work, because
|
||||
/// concatenated entries are indistinguishable from a legitimate space-bearing EVENT such as
|
||||
/// a <c>Message</c> reading <c>"Coolant level low"</c>. The one reliable discriminator —
|
||||
/// that the observation element had element children — exists only while the XML is still
|
||||
/// XML.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately a <b>neutral fact about the wire shape</b>, not a status: mapping it to a
|
||||
/// quality code is the observation index's job (it codes these
|
||||
/// <c>BadNotSupported</c> rather than publishing concatenated noise as Good). Defaults to
|
||||
/// <c>false</c>, the shape of every ordinary scalar observation.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record MTConnectObservation(
|
||||
string DataItemId,
|
||||
string Value,
|
||||
DateTime TimestampUtc,
|
||||
bool IsStructured = false);
|
||||
|
||||
/// <summary>
|
||||
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
|
||||
/// </summary>
|
||||
public static class MTConnectComponentTreeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enumerates every data item owned by this device or component, plus every data item owned
|
||||
/// by any component nested beneath it, to arbitrary depth. Order is depth-first: a node's
|
||||
/// own data items first, then each child component's data items in turn.
|
||||
/// </summary>
|
||||
/// <param name="container">The device or component to walk.</param>
|
||||
public static IEnumerable<MTConnectDataItem> AllDataItems(this IMTConnectComponentContainer container)
|
||||
{
|
||||
foreach (var dataItem in container.DataItems)
|
||||
{
|
||||
yield return dataItem;
|
||||
}
|
||||
|
||||
foreach (var child in container.Components)
|
||||
{
|
||||
foreach (var dataItem in child.AllDataItems())
|
||||
{
|
||||
yield return dataItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Frozen;
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The driver's live <c>dataItemId → <see cref="DataValueSnapshot"/></c> map: the single place
|
||||
/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
|
||||
/// timestamp. Written by the <c>/current</c> baseline read and by the <c>/sample</c> long-poll
|
||||
/// pump; read by <c>IReadable.ReadAsync</c> and the subscription fan-out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Quality mapping (design §3.3).</b> MTConnect has exactly one way to say "I have no
|
||||
/// value": the literal token <c>UNAVAILABLE</c>. It arrives as a Sample/Event's text, and —
|
||||
/// after <see cref="MTConnectStreamsParser"/> normalizes the <c><Unavailable/></c>
|
||||
/// element name — as a CONDITION's value on that same exact spelling. It maps to
|
||||
/// <c>BadNoCommunication</c> with a <c>null</c> value: the Agent is reachable, the machine's
|
||||
/// data item is not. The match is <b>ordinal and case-sensitive</b> against the one spelling
|
||||
/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
|
||||
/// EVENT whose value happens to read "Unavailable".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> This type sits under <c>IReadable</c> and must never throw across
|
||||
/// that boundary on account of observation content. Every unusable observation degrades to a
|
||||
/// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
|
||||
/// always see <i>when</i> the driver last heard about the tag even when it cannot show
|
||||
/// <i>what</i>. The only exceptions raised are <see cref="ArgumentNullException"/> for a null
|
||||
/// argument — a programming error, not data.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Status codes</b> are the canonical <c>Opc.Ua.StatusCodes</c> numeric values, declared
|
||||
/// once each below. Note that <see cref="BadTypeMismatch"/> is <c>0x80740000</c>: the
|
||||
/// <c>0x80730000</c> that four sibling drivers declare under that name is really
|
||||
/// <c>BadWriteNotSupported</c>, which would be actively misleading on a read path and
|
||||
/// renders as bare "Bad" in the driver CLI's <c>SnapshotFormatter</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Thread safety.</b> Backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> and
|
||||
/// guaranteed <b>per-key atomic</b> — nothing more, deliberately. <see cref="Apply"/> writes
|
||||
/// each dataItemId exactly once per call with a single whole-snapshot assignment, so a
|
||||
/// reader can never observe a torn snapshot (a Good code beside a stale value). It is
|
||||
/// explicitly <b>not</b> a whole-batch atomic swap: OPC UA reads are per-tag and the driver
|
||||
/// offers no cross-tag consistency contract, <c>/sample</c> chunks are deltas whose
|
||||
/// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
|
||||
/// would serialize the pump against every read for no semantic gain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
|
||||
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
|
||||
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
|
||||
/// real content in <c><Entry key=…></c> child elements, which reach a text-valued
|
||||
/// observation concatenated with the keys discarded — a two-entry set reads as the single
|
||||
/// token <c>"12"</c> — and because the type inference correctly demotes those
|
||||
/// representations to <see cref="DriverDataType.String"/>, coercion would otherwise
|
||||
/// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
|
||||
/// itself (no value heuristic can separate concatenated entries from a legitimate
|
||||
/// space-bearing EVENT such as a <c>Message</c> reading "Coolant level low"), so it relies on
|
||||
/// <see cref="MTConnectObservation.IsStructured"/>, which
|
||||
/// <see cref="MTConnectStreamsParser"/> sets while the XML is still XML.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class MTConnectObservationIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// The one token that means "the Agent has no value for this data item". Matched ordinally:
|
||||
/// the parser already normalizes a CONDITION's <c><Unavailable/></c> element name onto
|
||||
/// this exact spelling, so every category lands on one comparison.
|
||||
/// </summary>
|
||||
private const string UnavailableSentinel = "UNAVAILABLE";
|
||||
|
||||
private const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>The Agent reported <c>UNAVAILABLE</c>, or supplied no text at all.</summary>
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>The tag is authored but the Agent has not yet reported it.</summary>
|
||||
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>The dataItemId is neither authored nor ever observed.</summary>
|
||||
private const uint BadNodeIdUnknown = 0x80340000u;
|
||||
|
||||
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
|
||||
/// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
|
||||
private const uint BadNotSupported = 0x803D0000u;
|
||||
|
||||
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
|
||||
private const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
|
||||
/// several states reported for one dataItemId <i>within a single document</i> — see
|
||||
/// <see cref="Reconcile"/>. An active <c>Fault</c> outranks <c>UNAVAILABLE</c> because a
|
||||
/// fault is information and an absent value is not. Case-insensitive so a vendor's casing
|
||||
/// of a state element still ranks; the parser itself emits the canonical spellings.
|
||||
/// </summary>
|
||||
private static readonly FrozenDictionary<string, int> ConditionSeverity =
|
||||
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[UnavailableSentinel] = 0,
|
||||
["Normal"] = 1,
|
||||
["Warning"] = 2,
|
||||
["Fault"] = 3,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _snapshots = new(StringComparer.Ordinal);
|
||||
private readonly FrozenDictionary<string, MTConnectTagDefinition> _tags;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an index over the driver instance's authored tags.
|
||||
/// </summary>
|
||||
/// <param name="tags">
|
||||
/// The authored tags, keyed by <see cref="MTConnectTagDefinition.FullName"/> (the DataItem
|
||||
/// <c>id</c>) — normally <see cref="MTConnectDriverOptions.Tags"/>. <c>null</c> or empty is
|
||||
/// legal and means "no authored types": every observation is then indexed as its raw text
|
||||
/// under <see cref="DriverDataType.String"/>.
|
||||
/// <para>
|
||||
/// Nothing enforces <c>FullName</c> uniqueness at the options layer, so a duplicate is
|
||||
/// operator-authorable. <b>The last definition wins</b> rather than throwing: refusing to
|
||||
/// construct would turn an authoring slip into a driver that will not start, whereas
|
||||
/// last-wins is deterministic and costs only that one tag's coercion type.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="timeProvider">Clock behind <c>ServerTimestampUtc</c>; defaults to <see cref="TimeProvider.System"/>.</param>
|
||||
public MTConnectObservationIndex(
|
||||
IEnumerable<MTConnectTagDefinition>? tags = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
|
||||
foreach (var tag in tags ?? [])
|
||||
{
|
||||
if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
|
||||
|
||||
map[tag.FullName] = tag;
|
||||
}
|
||||
|
||||
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
|
||||
public int Count => _snapshots.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the index. Observations
|
||||
/// with no authored tag are indexed too — the Agent streams the whole device, so they are
|
||||
/// normal, and dropping them would make a streaming-but-not-yet-authored data item
|
||||
/// indistinguishable from one that does not exist.
|
||||
/// </summary>
|
||||
/// <param name="result">The parsed streams document. An observation-free result is a legal
|
||||
/// keep-alive and leaves the index untouched.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="result"/> is <c>null</c>.</exception>
|
||||
public void Apply(MTConnectStreamsResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
if (result.Observations is not { Count: > 0 }) return;
|
||||
|
||||
// Duplicates are reconciled on the RAW observations first, so each dataItemId is written
|
||||
// exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
|
||||
// — writing every observation and merging snapshots in place — would need a read-modify-write
|
||||
// per key and could expose a half-merged state to a concurrent reader.
|
||||
var resolved = new Dictionary<string, MTConnectObservation>(StringComparer.Ordinal);
|
||||
foreach (var observation in result.Observations)
|
||||
{
|
||||
if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
|
||||
|
||||
resolved[observation.DataItemId] =
|
||||
resolved.TryGetValue(observation.DataItemId, out var incumbent)
|
||||
? Reconcile(incumbent, observation)
|
||||
: observation;
|
||||
}
|
||||
|
||||
foreach (var (dataItemId, observation) in resolved)
|
||||
{
|
||||
_snapshots[dataItemId] = BuildSnapshot(observation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current snapshot for a DataItem. Never <c>null</c> and never throws: an
|
||||
/// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
|
||||
/// </summary>
|
||||
/// <param name="dataItemId">The DataItem <c>id</c> the tag binds.</param>
|
||||
/// <returns>
|
||||
/// The indexed snapshot; else <c>BadWaitingForInitialData</c> when the id is authored but
|
||||
/// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
|
||||
/// state); else <c>BadNodeIdUnknown</c>. The two are kept distinct because collapsing them
|
||||
/// would tell an operator their correctly-authored tag does not exist.
|
||||
/// </returns>
|
||||
public DataValueSnapshot Get(string dataItemId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(dataItemId) &&
|
||||
_snapshots.TryGetValue(dataItemId, out var snapshot))
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
|
||||
? BadWaitingForInitialData
|
||||
: BadNodeIdUnknown;
|
||||
|
||||
return new DataValueSnapshot(null, status, null, Now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
|
||||
/// <c>instanceId</c> changes, every value the index holds describes a device model that no
|
||||
/// longer exists, and serving it would be worse than serving nothing.
|
||||
/// </summary>
|
||||
public void Clear() => _snapshots.Clear();
|
||||
|
||||
private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the winner when one dataItemId appears more than once in a single document. A
|
||||
/// <c><Condition></c> container legitimately carries several <b>simultaneously active</b>
|
||||
/// states (a Fault and a Warning at once), so for a pair of recognized condition state words
|
||||
/// the <b>most severe</b> wins — plain last-wins would under-report an active Fault as a
|
||||
/// Warning purely because of element order. Anything else is last-wins, matching the Agent's
|
||||
/// ascending-sequence ordering.
|
||||
/// <para>
|
||||
/// Deliberately scoped to one document: a later <see cref="Apply"/> is the Agent's new
|
||||
/// statement of the state, so a cleared fault must fall back to Normal. A worst-of that
|
||||
/// spanned Applies would latch every fault forever.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
|
||||
{
|
||||
if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
|
||||
ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
|
||||
{
|
||||
return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
|
||||
}
|
||||
|
||||
return challenger;
|
||||
}
|
||||
|
||||
private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
|
||||
{
|
||||
var serverTimestamp = Now;
|
||||
var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
|
||||
|
||||
// No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
|
||||
// the only way to report absence, so an empty element is not an empty string value.
|
||||
if (string.IsNullOrWhiteSpace(observation.Value) ||
|
||||
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
|
||||
{
|
||||
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
// DATA_SET / TABLE: the Agent carried the content in <Entry key="…"> child elements, so
|
||||
// Value is their concatenated text with the keys discarded (a two-entry set reads "12").
|
||||
// Publishing that as a Good string would be noise an operator would trust. Checked before
|
||||
// the tag lookup because it is a fact about the wire shape, true whether or not the data
|
||||
// item is authored — and after the sentinel, so an unavailable data set still reports the
|
||||
// truthful no-comms.
|
||||
if (observation.IsStructured)
|
||||
{
|
||||
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
_tags.TryGetValue(observation.DataItemId, out var tag);
|
||||
|
||||
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
|
||||
// arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
|
||||
// type mismatch, and emphatically not the first element parsed out and served as Good.
|
||||
// Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
|
||||
if (tag is { IsArray: true })
|
||||
{
|
||||
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
// An unauthored observation has no declared target type; String is the only coercion that
|
||||
// cannot fail, and it carries the Agent's text with no interpretation applied.
|
||||
var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);
|
||||
|
||||
return status == Good
|
||||
? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
|
||||
: new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the Agent's text to the tag's authored type. Every parse is
|
||||
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
|
||||
/// host running a comma-decimal culture would otherwise read <c>123.4567</c> as
|
||||
/// <c>1234567</c> — a silently wrong value under Good quality, the worst possible outcome.
|
||||
/// </summary>
|
||||
/// <returns><see cref="Good"/> on success; otherwise a Bad code and a <c>null</c> value.</returns>
|
||||
private static uint Coerce(string raw, DriverDataType type, out object? value)
|
||||
{
|
||||
value = null;
|
||||
var text = raw.Trim();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
// Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
|
||||
// never a sensible authoring for MTConnect, but degrading it to text is harmless where
|
||||
// rejecting it would break a tag for no protection.
|
||||
case DriverDataType.String:
|
||||
case DriverDataType.Reference:
|
||||
value = raw;
|
||||
return Good;
|
||||
|
||||
case DriverDataType.Boolean:
|
||||
if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
|
||||
if (text is "1") { value = true; return Good; }
|
||||
if (text is "0") { value = false; return Good; }
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.DateTime:
|
||||
if (DateTime.TryParse(
|
||||
text,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
||||
out var moment))
|
||||
{
|
||||
value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
|
||||
return Good;
|
||||
}
|
||||
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.Float32:
|
||||
if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
|
||||
{
|
||||
value = single;
|
||||
return Good;
|
||||
}
|
||||
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.Float64:
|
||||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
|
||||
{
|
||||
value = real;
|
||||
return Good;
|
||||
}
|
||||
|
||||
return BadTypeMismatch;
|
||||
|
||||
case DriverDataType.Int16:
|
||||
if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
|
||||
{
|
||||
value = i16;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.Int32:
|
||||
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
|
||||
{
|
||||
value = i32;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.Int64:
|
||||
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
|
||||
{
|
||||
value = i64;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.UInt16:
|
||||
if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
|
||||
{
|
||||
value = u16;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.UInt32:
|
||||
if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
|
||||
{
|
||||
value = u32;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case DriverDataType.UInt64:
|
||||
if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
|
||||
{
|
||||
value = u64;
|
||||
return Good;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
// A DriverDataType this driver has no rule for. Text always round-trips, so serving
|
||||
// it beats failing a tag over an enum member added later.
|
||||
value = raw;
|
||||
return Good;
|
||||
}
|
||||
|
||||
// Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
|
||||
// operator whether to retype the tag or widen it.
|
||||
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
|
||||
? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
|
||||
: BadTypeMismatch; // not a number at all
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Turns an MTConnect Agent <c>/probe</c> response (an <c>MTConnectDevices</c> XML document)
|
||||
/// into the neutral <see cref="MTConnectProbeModel"/>. Deliberately a pure, socket-free static
|
||||
/// so the whole parse is unit-testable against canned fixtures.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled rather than TrakHound.</b> The TrakHound packages Task 0 pinned
|
||||
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2 — both dropped in Task 7) contain <b>no</b>
|
||||
/// <c>IResponseDocumentFormatter</c> implementation — the XML formatter ships in the
|
||||
/// separate, unreferenced <c>MTConnect.NET-XML</c> package — so
|
||||
/// <c>ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)</c> answers
|
||||
/// <c>Success = false</c> / "Document Formatter Not found for "xml"" (verified by
|
||||
/// reflection + live invocation against this repo's own fixture, 2026-07-24). TrakHound also
|
||||
/// offers no socket-free public parse entry point, which the plan requires.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Three shapes of the wire format this parser is built around.</b>
|
||||
/// (1) A device may declare data items <i>directly</i> on <c><Device><DataItems></c>,
|
||||
/// outside any component — a walker that only recurses <c><Components></c> silently
|
||||
/// drops them.
|
||||
/// (2) The children of <c><Components></c> are named for the component <i>type</i>
|
||||
/// (<c><Controller></c>, <c><Path></c>, <c><Axes></c>, …) — there is no
|
||||
/// literal <c><Component></c> element — so <i>every</i> element child of
|
||||
/// <c><Components></c> is a component.
|
||||
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Element matching and
|
||||
/// attribute reading therefore go through <see cref="MTConnectXml"/>, whose two rules
|
||||
/// (match on local name, read attributes unqualified) are shared verbatim with
|
||||
/// <see cref="MTConnectStreamsParser"/> — see that type for why each rule exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One deliberate divergence from the streams parser: no BOM/whitespace trim here.</b>
|
||||
/// A <c>/probe</c> body arrives whole from <c>HttpContent</c>, so it begins exactly where
|
||||
/// the Agent's document begins. A <c>/sample</c> chunk, by contrast, is lifted out of a
|
||||
/// multipart frame and can carry a byte-order mark or the framing's own line break ahead of
|
||||
/// the XML declaration, which <c>XmlReader</c> rejects — hence the trim there and not here.
|
||||
/// This asymmetry is intentional, not an oversight.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
|
||||
/// <see cref="InvalidDataException"/>. It never degrades to an empty-but-successful model:
|
||||
/// an empty device model that looks successful is precisely the defect class that has bitten
|
||||
/// this codebase before (#485), and here it would tear the driver's browse tree down to
|
||||
/// nothing while reporting healthy.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MTConnectProbeParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Bound on component nesting depth. Real device models nest a handful of levels; this only
|
||||
/// exists so a pathological or hostile document cannot recurse the parser into a
|
||||
/// process-fatal (uncatchable) stack overflow.
|
||||
/// </summary>
|
||||
private const int MaxComponentDepth = 64;
|
||||
|
||||
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
|
||||
private const string Subject = "/probe response";
|
||||
|
||||
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
|
||||
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
|
||||
/// <exception cref="InvalidDataException">
|
||||
/// The payload is empty, not well-formed XML, not an <c>MTConnectDevices</c> document (an
|
||||
/// Agent <c>MTConnectError</c> document included — Agents answer a bad device path with one
|
||||
/// under HTTP 200), or declares no usable device.
|
||||
/// </exception>
|
||||
public static MTConnectProbeModel Parse(string xml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
{
|
||||
throw new InvalidDataException("MTConnect /probe response was empty; expected an MTConnectDevices XML document.");
|
||||
}
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
document = XDocument.Parse(xml);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
return Build(document);
|
||||
}
|
||||
|
||||
/// <summary>Parses a <c>/probe</c> response read from a stream.</summary>
|
||||
/// <param name="stream">A stream positioned at the start of the XML document.</param>
|
||||
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
|
||||
public static MTConnectProbeModel Parse(Stream stream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
document = XDocument.Load(stream);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
return Build(document);
|
||||
}
|
||||
|
||||
private static MTConnectProbeModel Build(XDocument document)
|
||||
{
|
||||
var root = document.Root
|
||||
?? throw new InvalidDataException("MTConnect /probe response has no root element.");
|
||||
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
|
||||
{
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
|
||||
}
|
||||
|
||||
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect /probe response has no <Devices> element; it does not describe a device model.");
|
||||
|
||||
var devices = devicesContainer.Elements().Select(ReadDevice).ToList();
|
||||
if (devices.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe.");
|
||||
}
|
||||
|
||||
return new MTConnectProbeModel(devices);
|
||||
}
|
||||
|
||||
private static MTConnectDevice ReadDevice(XElement element) =>
|
||||
new(
|
||||
MTConnectXml.RequiredAttribute(element, "id", "Device", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
ReadComponents(element, depth: 1),
|
||||
ReadDataItems(element));
|
||||
|
||||
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
|
||||
new(
|
||||
MTConnectXml.RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
ReadComponents(element, depth + 1),
|
||||
ReadDataItems(element));
|
||||
|
||||
/// <summary>
|
||||
/// Every element child of this container's <c><Components></c> element, whatever it is
|
||||
/// named — the element name is the component type, not the literal string "Component".
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MTConnectComponent> ReadComponents(XElement container, int depth)
|
||||
{
|
||||
if (depth > MaxComponentDepth)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
|
||||
}
|
||||
|
||||
return MTConnectXml.ChildrenNamed(container, "Components")
|
||||
.SelectMany(components => components.Elements())
|
||||
.Select(element => ReadComponent(element, depth))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
|
||||
MTConnectXml.ChildrenNamed(container, "DataItems")
|
||||
.SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
|
||||
.Select(ReadDataItem)
|
||||
.ToList();
|
||||
|
||||
private static MTConnectDataItem ReadDataItem(XElement element)
|
||||
{
|
||||
var id = MTConnectXml.RequiredAttribute(element, "id", "DataItem", Subject);
|
||||
|
||||
return new MTConnectDataItem(
|
||||
id,
|
||||
MTConnectXml.OptionalAttribute(element, "name"),
|
||||
MTConnectXml.RequiredAttribute(element, "category", $"DataItem '{id}'", Subject),
|
||||
MTConnectXml.RequiredAttribute(element, "type", $"DataItem '{id}'", Subject),
|
||||
MTConnectXml.OptionalAttribute(element, "subType"),
|
||||
MTConnectXml.OptionalAttribute(element, "units"),
|
||||
MTConnectXml.OptionalAttribute(element, "representation"),
|
||||
MTConnectXml.OptionalIntAttribute(element, "sampleCount", $"DataItem '{id}'", Subject));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-internal identity of one <c>/sample</c> subscription, matching the sibling drivers'
|
||||
/// shape (Galaxy's <c>GalaxySubscriptionHandle</c>, the shared <c>PollGroupEngine</c>'s
|
||||
/// <c>PollSubscriptionHandle</c>): a monotonic per-driver id plus a diagnostic string that
|
||||
/// carries it into logs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Every handle shares ONE Agent stream.</b> Unlike a polled driver, where each
|
||||
/// subscription owns a loop, MTConnect's <c>/sample</c> long poll is per-Agent: the driver
|
||||
/// opens exactly one and fans each chunk out to whichever handles subscribe the reporting
|
||||
/// DataItem. The handle is therefore purely an identity — it owns no connection, no task,
|
||||
/// and no cursor — and dropping one only stops the stream when it was the last.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <see langword="record"/> for value equality on the id, so a handle that has round-tripped
|
||||
/// through the caller still resolves. The id is never reused within a driver instance.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="SubscriptionId">The monotonic per-driver subscription id.</param>
|
||||
internal sealed record MTConnectSampleHandle(long SubscriptionId) : ISubscriptionHandle
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public string DiagnosticId => $"mtconnect-sub-{SubscriptionId}";
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for every way an Agent <c>/sample</c> long poll can stop delivering chunks for a
|
||||
/// reason that is <b>not</b> the caller cancelling it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why these are exceptions rather than a normal end of enumeration.</b>
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/> is contracted to yield indefinitely until
|
||||
/// its token is cancelled, so a pump written to that contract has no reason to handle normal
|
||||
/// completion — it would either drop the subscription silently or hot-loop reconnecting on
|
||||
/// an enumerator that ends immediately. Returning normally would make "the data path
|
||||
/// stopped" indistinguishable from "the data path is idle", which is the
|
||||
/// quiet-successful-looking-termination shape that produced this repo's #485 defect. Every
|
||||
/// non-cancellation end is therefore loud, typed, and carries the Agent URI.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Catch this base type to handle "the stream is over" uniformly; catch the two derived
|
||||
/// types to distinguish a <i>transient</i> end (the Agent closed a connection — reconnect)
|
||||
/// from a <i>configuration</i> error (the Agent will never stream to this request — retrying
|
||||
/// is pointless until an operator changes something).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class MTConnectStreamException : Exception
|
||||
{
|
||||
/// <summary>Creates the exception with no message.</summary>
|
||||
protected MTConnectStreamException()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
protected MTConnectStreamException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message and an inner cause.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
/// <param name="innerException">The underlying cause.</param>
|
||||
protected MTConnectStreamException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How an Agent <c>/sample</c> stream ended.</summary>
|
||||
public enum MTConnectStreamEndReason
|
||||
{
|
||||
/// <summary>
|
||||
/// The transport closed mid-stream — the connection dropped, the Agent restarted, or a proxy
|
||||
/// timed the connection out. Ordinary and transient: reconnect from the last
|
||||
/// <see cref="MTConnectStreamsResult.NextSequence"/>.
|
||||
/// </summary>
|
||||
ConnectionClosed = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The Agent wrote the closing <c>--boundary--</c> delimiter, ending the multipart response
|
||||
/// deliberately. Usually means the request was bounded (an Agent honouring a <c>count</c>
|
||||
/// that exhausted, or an administrative stop) rather than a fault. Also reconnectable, but
|
||||
/// worth distinguishing in logs: a stream that keeps closing cleanly points at the request's
|
||||
/// parameters, not at the network.
|
||||
/// </summary>
|
||||
ClosingBoundary = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent's <c>/sample</c> stream ended without the caller cancelling it. See
|
||||
/// <see cref="MTConnectStreamException"/> for why this is not a normal end of enumeration.
|
||||
/// </summary>
|
||||
public sealed class MTConnectStreamEndedException : MTConnectStreamException
|
||||
{
|
||||
/// <summary>Creates the exception for a stream that ended for <paramref name="reason"/>.</summary>
|
||||
/// <param name="reason">How the stream ended.</param>
|
||||
/// <param name="agentUri">The <c>/sample</c> request URI whose stream ended.</param>
|
||||
/// <param name="chunksDelivered">How many chunks were yielded before the end.</param>
|
||||
public MTConnectStreamEndedException(MTConnectStreamEndReason reason, string agentUri, long chunksDelivered)
|
||||
: base($"MTConnect /sample stream from '{agentUri}' ended after {chunksDelivered} chunk(s) without the caller cancelling it ({Describe(reason)}). The stream is contracted to run until cancelled, so this is reported rather than returned; resume from the last chunk's nextSequence.")
|
||||
{
|
||||
Reason = reason;
|
||||
AgentUri = agentUri;
|
||||
ChunksDelivered = chunksDelivered;
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
public MTConnectStreamEndedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
AgentUri = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message and an inner cause.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
/// <param name="innerException">The underlying cause.</param>
|
||||
public MTConnectStreamEndedException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
AgentUri = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with no message.</summary>
|
||||
public MTConnectStreamEndedException()
|
||||
{
|
||||
AgentUri = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>How the stream ended.</summary>
|
||||
public MTConnectStreamEndReason Reason { get; }
|
||||
|
||||
/// <summary>The <c>/sample</c> request URI whose stream ended.</summary>
|
||||
public string AgentUri { get; }
|
||||
|
||||
/// <summary>How many chunks were yielded before the stream ended.</summary>
|
||||
public long ChunksDelivered { get; }
|
||||
|
||||
private static string Describe(MTConnectStreamEndReason reason) =>
|
||||
reason switch
|
||||
{
|
||||
MTConnectStreamEndReason.ClosingBoundary => "the Agent wrote the closing multipart boundary",
|
||||
_ => "the connection closed mid-stream"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Agent answered a <c>/sample</c> request with something that cannot be consumed as a
|
||||
/// stream at all — a single non-multipart document, or a <c>multipart/*</c> response with no
|
||||
/// <c>boundary</c> parameter to frame it by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a <b>configuration</b> error, not a transient one, and is deliberately typed apart
|
||||
/// from <see cref="MTConnectStreamEndedException"/>: reconnecting will produce the identical
|
||||
/// response forever. The usual cause is an endpoint that is not an MTConnect Agent (a reverse
|
||||
/// proxy, a load balancer's health page, or an Agent fronted by something that buffers and
|
||||
/// collapses <c>multipart/x-mixed-replace</c>). Reported instead of yielding the one document
|
||||
/// the Agent did return, because a single snapshot followed by a healthy-looking finished
|
||||
/// stream is exactly how a dead data path disguises itself as a working one.
|
||||
/// </remarks>
|
||||
public sealed class MTConnectStreamNotSupportedException : MTConnectStreamException
|
||||
{
|
||||
/// <summary>Creates the exception with a message.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
public MTConnectStreamNotSupportedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with a message and an inner cause.</summary>
|
||||
/// <param name="message">The operator-facing description.</param>
|
||||
/// <param name="innerException">The underlying cause.</param>
|
||||
public MTConnectStreamNotSupportedException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception with no message.</summary>
|
||||
public MTConnectStreamNotSupportedException()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Turns an MTConnect Agent <c>MTConnectStreams</c> document — a <c>/current</c> snapshot or one
|
||||
/// chunk of a <c>/sample</c> multipart stream — into the neutral
|
||||
/// <see cref="MTConnectStreamsResult"/>. Deliberately a pure, socket-free static so the whole
|
||||
/// parse is unit-testable against canned fixtures; chunk framing lives in
|
||||
/// <see cref="MTConnectAgentClient"/> and hands this parser one chunk's XML at a time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled rather than TrakHound.</b> Same finding as
|
||||
/// <see cref="MTConnectProbeParser"/>: the packages pinned by Task 0 ship no XML document
|
||||
/// formatter, and TrakHound's streaming client offers no socket-free entry point. Both
|
||||
/// package references were dropped in Task 7; the driver depends only on the BCL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The Streams document is shaped nothing like the Devices document</b>, and three
|
||||
/// differences drive this parser's design.
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>The observation element's name is the data item's type in PascalCase</b>
|
||||
/// (<c>Position</c>, <c>PartCount</c>, <c>Execution</c>) — not the probe's
|
||||
/// UPPER_SNAKE <c>POSITION</c> / <c>PART_COUNT</c>. The standard defines hundreds of
|
||||
/// types and vendors add more, so matching a fixed set of element names would drop
|
||||
/// observations silently. <b>Every</b> element child of <c><Samples></c>,
|
||||
/// <c><Events></c> and <c><Condition></c> is therefore an observation,
|
||||
/// keyed by its <c>dataItemId</c> attribute — the only identity the probe model and
|
||||
/// the streams document actually share.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>A CONDITION observation's value is its ELEMENT NAME, not its text.</b>
|
||||
/// <c><Normal/></c> / <c><Warning/></c> / <c><Fault/></c> /
|
||||
/// <c><Unavailable/></c> carry the state in the name and are typically empty;
|
||||
/// where text is present it is the operator-facing message, not the state. Reading
|
||||
/// the text would report every condition as an empty string.
|
||||
/// <c><Unavailable/></c> is normalized to the literal
|
||||
/// <see cref="UnavailableSentinel"/> so a no-comms condition lands on exactly the
|
||||
/// same sentinel as a Sample/Event whose text is <c>UNAVAILABLE</c> — the
|
||||
/// observation index maps that one token to bad quality.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>Timestamps are normalized to UTC explicitly.</b> MTConnect timestamps are
|
||||
/// ISO-8601 Zulu, but a bare <c>DateTime.Parse</c> yields <c>Local</c> or
|
||||
/// <c>Unspecified</c> depending on machine settings — and this value becomes the
|
||||
/// OPC UA SourceTimestamp, so a wrong <see cref="DateTimeKind"/> shifts every
|
||||
/// timestamp by the host's UTC offset with nothing to show for it.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> A malformed document, a non-<c>MTConnectStreams</c> root (an Agent
|
||||
/// <c>MTConnectError</c> served under HTTP 200 included), an unusable header, or an
|
||||
/// observation missing its <c>dataItemId</c>/<c>timestamp</c> all throw
|
||||
/// <see cref="InvalidDataException"/>. A document with a valid header and <i>zero</i>
|
||||
/// observations, by contrast, is entirely legal — <c>/sample</c> chunks are deltas and an
|
||||
/// idle Agent's keep-alive is an observation-free document — so it parses to an empty
|
||||
/// observation list rather than throwing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MTConnectStreamsParser
|
||||
{
|
||||
/// <summary>
|
||||
/// The single token that means "the Agent has no value for this data item". Sample/Event
|
||||
/// observations carry it as text; a CONDITION carries it as the element name
|
||||
/// <c><Unavailable/></c> and is normalized onto this spelling here.
|
||||
/// </summary>
|
||||
private const string UnavailableSentinel = "UNAVAILABLE";
|
||||
|
||||
private const string ConditionContainer = "Condition";
|
||||
|
||||
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
|
||||
private const string Subject = "streams response";
|
||||
|
||||
/// <summary>How those messages name the header element.</summary>
|
||||
private const string HeaderOwner = "the <Header>";
|
||||
|
||||
/// <summary>
|
||||
/// The three elements whose element children are observations. A ComponentStream may carry
|
||||
/// any subset of them, including none.
|
||||
/// </summary>
|
||||
private static readonly string[] ObservationContainers = ["Samples", "Events", ConditionContainer];
|
||||
|
||||
/// <summary>Parses a <c>/current</c> response, or one <c>/sample</c> chunk, held as a string.</summary>
|
||||
/// <param name="xml">The raw <c>MTConnectStreams</c> XML document.</param>
|
||||
/// <exception cref="InvalidDataException">
|
||||
/// The payload is empty, not well-formed XML, not an <c>MTConnectStreams</c> document (an
|
||||
/// Agent <c>MTConnectError</c> included — Agents answer a bad request with one under HTTP
|
||||
/// 200), carries no usable <c><Header></c>, or carries a malformed observation.
|
||||
/// </exception>
|
||||
public static MTConnectStreamsResult Parse(string xml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
|
||||
}
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
// A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
|
||||
// trailing line break; XmlReader rejects either ahead of the XML declaration.
|
||||
document = XDocument.Parse(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
return Build(document);
|
||||
}
|
||||
|
||||
/// <summary>Parses a <c>/current</c> response read from a stream.</summary>
|
||||
/// <param name="stream">A stream positioned at the start of the XML document.</param>
|
||||
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
|
||||
public static MTConnectStreamsResult Parse(Stream stream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
XDocument document;
|
||||
try
|
||||
{
|
||||
document = XDocument.Load(stream);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
return Build(document);
|
||||
}
|
||||
|
||||
private static MTConnectStreamsResult Build(XDocument document)
|
||||
{
|
||||
var root = document.Root
|
||||
?? throw new InvalidDataException("MTConnect streams response has no root element.");
|
||||
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectStreams"))
|
||||
{
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectStreams", Subject));
|
||||
}
|
||||
|
||||
var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault()
|
||||
?? throw new InvalidDataException(
|
||||
"MTConnect streams response has no <Header>; without it the sequence bookkeeping the sample pump runs on is unknowable.");
|
||||
|
||||
return new MTConnectStreamsResult(
|
||||
MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
|
||||
ReadObservations(root));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects every observation in the document, in Agent-supplied order. Containers are found
|
||||
/// by descending the whole <c><Streams></c> subtree rather than by walking a fixed
|
||||
/// DeviceStream → ComponentStream chain, so a vendor's extra nesting level cannot silently
|
||||
/// hide a device's observations.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MTConnectObservation> ReadObservations(XElement root)
|
||||
{
|
||||
var observations = new List<MTConnectObservation>();
|
||||
|
||||
foreach (var container in root.Descendants().Where(IsObservationContainer))
|
||||
{
|
||||
var isCondition = MTConnectXml.IsNamed(container, ConditionContainer);
|
||||
foreach (var element in container.Elements())
|
||||
{
|
||||
observations.Add(ReadObservation(element, isCondition));
|
||||
}
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
|
||||
private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
|
||||
{
|
||||
var dataItemId = MTConnectXml.RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>", Subject);
|
||||
|
||||
return new MTConnectObservation(
|
||||
dataItemId,
|
||||
isCondition ? ConditionState(element) : element.Value.Trim(),
|
||||
ReadTimestampUtc(element, dataItemId),
|
||||
|
||||
// A DATA_SET/TABLE observation keeps its content in <Entry key="…"> children, so the
|
||||
// text read above concatenates them into nonsense ("12" for two entries). Flagged here
|
||||
// because this is the only layer that can still see the distinction — see
|
||||
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
|
||||
// the element NAME, so child content cannot corrupt it, and flagging one would throw
|
||||
// away a perfectly well-determined state.
|
||||
IsStructured: !isCondition && element.HasElements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A condition's state is its element name. <c>Unavailable</c> is normalized to the
|
||||
/// <see cref="UnavailableSentinel"/> spelling so downstream quality mapping has exactly one
|
||||
/// token to recognize; every other state is reported as-named (<c>Normal</c>, <c>Warning</c>,
|
||||
/// <c>Fault</c>, and any vendor state).
|
||||
/// </summary>
|
||||
private static string ConditionState(XElement element)
|
||||
{
|
||||
var state = element.Name.LocalName;
|
||||
|
||||
return string.Equals(state, "Unavailable", StringComparison.OrdinalIgnoreCase) ? UnavailableSentinel : state;
|
||||
}
|
||||
|
||||
private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
|
||||
{
|
||||
var raw = MTConnectXml.RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'", Subject);
|
||||
|
||||
if (!DateTime.TryParse(
|
||||
raw,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect streams response is malformed: observation '{dataItemId}' has an unparseable 'timestamp' ('{raw}').");
|
||||
}
|
||||
|
||||
// AdjustToUniversal already yields Utc; stated explicitly so a future styles change cannot
|
||||
// quietly hand the OPC UA layer a Local or Unspecified SourceTimestamp.
|
||||
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private static bool IsObservationContainer(XElement element) =>
|
||||
ObservationContainers.Any(name => MTConnectXml.IsNamed(element, name));
|
||||
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Globalization;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The element/attribute reading rules shared by <see cref="MTConnectProbeParser"/> and
|
||||
/// <see cref="MTConnectStreamsParser"/>. Both documents are the same dialect of XML and must be
|
||||
/// read by the same rules; keeping one copy is what stops the two parsers drifting apart on the
|
||||
/// two rules below, either of which fails silently rather than loudly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Rule 1: elements are matched on <see cref="XName.LocalName"/>, ignoring the
|
||||
/// namespace.</b> The namespace URI carries the MTConnect version
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>), so a fully-qualified match
|
||||
/// would hard-code a version. It also silently drops vendor-extension elements declared in
|
||||
/// their own namespace, whose standard children still inherit the default MTConnect
|
||||
/// namespace — the browse tree simply comes back short, with no error anywhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Rule 2: attributes are read <i>unqualified</i>.</b> A prefixed attribute
|
||||
/// (<c>xsi:type</c> on a document root, a vendor's <c>x:dataItemId</c>) must never be
|
||||
/// mistaken for the real <c>type</c> / <c>dataItemId</c>, which would invent a data item the
|
||||
/// probe never declared.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MTConnectXml
|
||||
{
|
||||
/// <summary>Does <paramref name="element"/> have the given local name, whatever its namespace?</summary>
|
||||
/// <param name="element">The element to test.</param>
|
||||
/// <param name="localName">The unqualified name to match.</param>
|
||||
public static bool IsNamed(XElement element, string localName) =>
|
||||
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Direct element children of <paramref name="parent"/> with the given local name.</summary>
|
||||
/// <param name="parent">The element whose children to filter.</param>
|
||||
/// <param name="localName">The unqualified name to match.</param>
|
||||
public static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
|
||||
parent.Elements().Where(child => IsNamed(child, localName));
|
||||
|
||||
/// <summary>
|
||||
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
|
||||
/// See the type-level remarks for why only unqualified attributes are considered.
|
||||
/// </summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
public static string? OptionalAttribute(XElement element, string name)
|
||||
{
|
||||
var value = element.Attribute(name)?.Value;
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
/// <summary>Reads a required unqualified attribute, or throws naming the owner and the attribute.</summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
/// <param name="owner">How to describe the element in the error message.</param>
|
||||
/// <param name="subject">How to describe the document in the error message (e.g. "/probe response").</param>
|
||||
/// <exception cref="InvalidDataException">The attribute is absent or blank.</exception>
|
||||
public static string RequiredAttribute(XElement element, string name, string owner, string subject)
|
||||
{
|
||||
var value = OptionalAttribute(element, name);
|
||||
|
||||
return value ?? throw new InvalidDataException(
|
||||
$"MTConnect {subject} is malformed: {owner} has no '{name}' attribute.");
|
||||
}
|
||||
|
||||
/// <summary>Reads an optional unqualified attribute as an <see cref="int"/>.</summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
/// <param name="owner">How to describe the element in the error message.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
/// <exception cref="InvalidDataException">The attribute is present but not an integer.</exception>
|
||||
public static int? OptionalIntAttribute(XElement element, string name, string owner, string subject)
|
||||
{
|
||||
var raw = OptionalAttribute(element, name);
|
||||
if (raw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>Reads a required unqualified attribute as a <see cref="long"/>.</summary>
|
||||
/// <param name="element">The element carrying the attribute.</param>
|
||||
/// <param name="name">The unqualified attribute name.</param>
|
||||
/// <param name="owner">How to describe the element in the error message.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
/// <exception cref="InvalidDataException">The attribute is absent, blank, or not an integer.</exception>
|
||||
public static long RequiredLongAttribute(XElement element, string name, string owner, string subject)
|
||||
{
|
||||
var raw = RequiredAttribute(element, name, owner, subject);
|
||||
|
||||
if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the error message for a document whose root is not what was expected, lifting the
|
||||
/// Agent's own error text when the response is an <c>MTConnectError</c> document — which
|
||||
/// Agents return under <b>HTTP 200</b>, so <c>EnsureSuccessStatusCode</c> never fires and
|
||||
/// this is the only place the operator can learn what the Agent objected to.
|
||||
/// </summary>
|
||||
/// <param name="root">The document's actual root element.</param>
|
||||
/// <param name="expectedRootName">The root element name the caller required.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
|
||||
{
|
||||
var message =
|
||||
$"MTConnect {subject} root element is <{root.Name.LocalName}>, expected <{expectedRootName}>.";
|
||||
|
||||
if (!IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
return message;
|
||||
}
|
||||
|
||||
var errors = root.Descendants()
|
||||
.Where(e => IsNamed(e, "Error"))
|
||||
.Select(e =>
|
||||
{
|
||||
var code = OptionalAttribute(e, "errorCode");
|
||||
var text = e.Value.Trim();
|
||||
|
||||
return code is null ? text : $"{code}: {text}";
|
||||
})
|
||||
.Where(text => text.Length > 0)
|
||||
.ToList();
|
||||
|
||||
return errors.Count == 0
|
||||
? message
|
||||
: $"{message} The Agent reported: {string.Join("; ", errors)}";
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.MTConnect</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- No backend NuGet: the Agent surface is plain HTTP + XML, served entirely by the BCL
|
||||
(HttpClient + System.Xml.Linq). The TrakHound MTConnect.NET-Common/-HTTP references Task 0
|
||||
added were removed in Task 7 — they can neither parse an MTConnect document (no XML
|
||||
formatter ships in either package) nor frame the /sample stream socket-free (their client
|
||||
type dials its own URL). See the Task 0 CORRECTION block in
|
||||
docs/plans/2026-07-24-mtconnect-driver.md. -->
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
|
||||
/// </summary>
|
||||
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
/// <summary>
|
||||
/// Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
|
||||
/// historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
|
||||
/// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
|
||||
/// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
|
||||
/// </summary>
|
||||
public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, the driver suppresses redundant writes: if the most recent
|
||||
/// successful write to a tag carried value V and a new write of V arrives, the second
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
|
||||
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
|
||||
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
|
||||
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
|
||||
/// do not number-squat it.
|
||||
/// </summary>
|
||||
public enum ModbusTransportMode
|
||||
{
|
||||
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
|
||||
Tcp,
|
||||
|
||||
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
|
||||
RtuOverTcp,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// CRC-16/MODBUS helper: reflected polynomial <c>0xA001</c>, initial value <c>0xFFFF</c>.
|
||||
/// On the wire the CRC is appended low byte first, high byte second — <see cref="Append"/>
|
||||
/// does that; <see cref="Compute"/> just returns the 16-bit value. Byte-stream-agnostic:
|
||||
/// no socket or framing knowledge lives here.
|
||||
/// </summary>
|
||||
public static class ModbusCrc
|
||||
{
|
||||
/// <summary>Computes the CRC-16/MODBUS checksum over <paramref name="data"/>.</summary>
|
||||
/// <param name="data">The bytes to checksum (e.g. the RTU frame minus the trailing CRC).</param>
|
||||
/// <returns>The 16-bit CRC value.</returns>
|
||||
public static ushort Compute(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ushort crc = 0xFFFF;
|
||||
foreach (var b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var carry = (crc & 0x0001) != 0;
|
||||
crc >>= 1;
|
||||
if (carry)
|
||||
{
|
||||
crc ^= 0xA001;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <paramref name="body"/> with its CRC-16/MODBUS appended, low byte first.
|
||||
/// </summary>
|
||||
/// <param name="body">The frame body to checksum.</param>
|
||||
/// <returns>A new array: <paramref name="body"/> followed by <c>[crc & 0xFF, crc >> 8]</c>.</returns>
|
||||
public static byte[] Append(ReadOnlySpan<byte> body)
|
||||
{
|
||||
var crc = Compute(body);
|
||||
var result = new byte[body.Length + 2];
|
||||
body.CopyTo(result);
|
||||
result[^2] = (byte)(crc & 0xFF);
|
||||
result[^1] = (byte)(crc >> 8);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ public sealed class ModbusDriver
|
||||
{
|
||||
// ---- instance fields (grouped at top for auditability) ----
|
||||
|
||||
private readonly ModbusDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/transport uses it.</summary>
|
||||
private ModbusDriverOptions _options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ILogger<ModbusDriver> _logger;
|
||||
@@ -94,7 +96,8 @@ public sealed class ModbusDriver
|
||||
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
|
||||
/// <param name="options">Driver configuration options.</param>
|
||||
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
|
||||
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to ModbusTcpTransport.</param>
|
||||
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to
|
||||
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
|
||||
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
|
||||
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
|
||||
@@ -107,11 +110,7 @@ public sealed class ModbusDriver
|
||||
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
_transportFactory = transportFactory
|
||||
?? (o => new ModbusTcpTransport(
|
||||
o.Host, o.Port, o.Timeout, o.AutoReconnect,
|
||||
keepAlive: o.KeepAlive,
|
||||
idleDisconnect: o.IdleDisconnectTimeout,
|
||||
reconnect: o.Reconnect));
|
||||
?? (o => ModbusTransportFactory.Create(o));
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
@@ -191,12 +190,29 @@ public sealed class ModbusDriver
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
|
||||
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
|
||||
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with, so an operator's edit is discarded while the deployment still seals green. An empty /
|
||||
// placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
_transport = _transportFactory(_options);
|
||||
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
|
||||
|
||||
@@ -44,6 +44,23 @@ public static class ModbusDriverFactoryExtensions
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
|
||||
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
return new ModbusDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
transportFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Modbus <c>DriverConfig</c> JSON document into typed options. Extracted from
|
||||
/// <see cref="CreateInstance(string,string,ILoggerFactory?)"/> so <see cref="ModbusDriver.InitializeAsync"/>
|
||||
/// can re-parse a CHANGED config on reinitialize (Gitea #516) rather than serving the options it was
|
||||
/// constructed with — which silently discarded every edit while the deployment still sealed green.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||
/// <returns>The parsed <see cref="ModbusDriverOptions"/>.</returns>
|
||||
internal static ModbusDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -74,6 +91,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
|
||||
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
|
||||
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
|
||||
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
|
||||
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
|
||||
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
|
||||
AutoReconnect = dto.AutoReconnect ?? true,
|
||||
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
|
||||
@@ -104,10 +123,7 @@ public static class ModbusDriverFactoryExtensions
|
||||
},
|
||||
};
|
||||
|
||||
return new ModbusDriver(
|
||||
options, driverInstanceId,
|
||||
transportFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
||||
return options;
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
||||
@@ -159,6 +175,8 @@ public static class ModbusDriverFactoryExtensions
|
||||
public string? Family { get; init; }
|
||||
/// <summary>Gets or sets the Melsec subfamily.</summary>
|
||||
public string? MelsecSubFamily { get; init; }
|
||||
/// <summary>Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.</summary>
|
||||
public string? Transport { get; init; }
|
||||
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
|
||||
public int? AutoProhibitReprobeMs { get; init; }
|
||||
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
|
||||
|
||||
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read
|
||||
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so
|
||||
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary>
|
||||
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout);
|
||||
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
|
||||
/// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
|
||||
/// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
|
||||
/// OpcUaClient parity rule) and an <c>RtuOverTcp</c>-authored config probes over RTU framing —
|
||||
/// matching how the driver will actually run.</summary>
|
||||
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
|
||||
|
||||
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
|
||||
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
|
||||
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
|
||||
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary>
|
||||
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used. <c>Transport</c>
|
||||
/// mirrors the factory's null-defaults-to-Tcp convention (<see cref="ModbusDriverFactoryExtensions"/>);
|
||||
/// an unrecognized value is reported as a probe-target error rather than silently falling back.</summary>
|
||||
/// <param name="configJson">The driver config JSON (factory DTO shape).</param>
|
||||
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
|
||||
/// <returns>The parsed target, or a null target with an error string.</returns>
|
||||
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
|
||||
return (null, "Config has no host/port to probe.");
|
||||
|
||||
var transportMode = ModbusTransportMode.Tcp;
|
||||
if (dto.Transport is not null && !Enum.TryParse(dto.Transport, ignoreCase: true, out transportMode))
|
||||
{
|
||||
return (null, $"Config has unknown Transport '{dto.Transport}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<ModbusTransportMode>())}");
|
||||
}
|
||||
|
||||
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
|
||||
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null);
|
||||
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout, transportMode), null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference).
|
||||
// Phase 1 — TCP connect, over whichever transport the config's Transport mode selects
|
||||
// (ModbusTransportFactory is the single mapping point — same switch the live driver uses).
|
||||
// autoReconnect=false: this is a one-shot probe, no retry loops.
|
||||
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false);
|
||||
var transport = ModbusTransportFactory.Create(new ModbusDriverOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Timeout = timeout,
|
||||
Transport = t.Transport,
|
||||
AutoReconnect = false,
|
||||
});
|
||||
await using (transport.ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus RTU framing: builds a request ADU (<c>[unitId] + PDU + CRC</c>) and deframes a
|
||||
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a <see cref="Stream"/>
|
||||
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries <b>no
|
||||
/// length field</b> (unlike TCP's MBAP header), so the response size must be derived from
|
||||
/// the function code. After reading <c>addr(1)</c> + <c>fc(1)</c>, the remaining byte count
|
||||
/// is one of three shapes:
|
||||
/// </para>
|
||||
/// <list type="table">
|
||||
/// <listheader><term>Shape</term><description>Trailing bytes after <c>addr,fc</c></description></listheader>
|
||||
/// <item>
|
||||
/// <term>Exception (<c>fc & 0x80</c>)</term>
|
||||
/// <description><c>excCode(1)</c> + <c>CRC(2)</c></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Read (FC 01/02/03/04)</term>
|
||||
/// <description><c>byteCount(1)</c> then <c>byteCount</c> data bytes + <c>CRC(2)</c></description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <term>Write echo (FC 05/06/15/16)</term>
|
||||
/// <description>fixed <c>4</c> bytes + <c>CRC(2)</c></description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The trailing CRC is validated over <c>[addr, ...pdu]</c> (everything except the two CRC
|
||||
/// bytes) <b>before</b> the frame is interpreted — so a corrupt exception frame surfaces as a
|
||||
/// desync, never as a bogus <see cref="ModbusException"/>. A CRC mismatch or a short read
|
||||
/// (truncation / stream closed mid-frame) throws <see cref="ModbusTransportDesyncException"/>;
|
||||
/// a CRC-valid exception PDU throws <see cref="ModbusException"/> carrying the original
|
||||
/// function code (<c>fc & 0x7F</c>) and exception code.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Once the CRC passes, the response's address byte is validated against the addressed unit.
|
||||
/// On an RS-485 multi-drop bus a CRC-valid reply from the <b>wrong</b> slave would otherwise be
|
||||
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
|
||||
/// <see cref="ModbusTransportDesyncException"/>. This ordering is deliberate — a corrupt frame
|
||||
/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
|
||||
/// unit-mismatch desync.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ModbusRtuFraming
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
|
||||
/// (appended low byte first).
|
||||
/// </summary>
|
||||
/// <param name="unitId">The RTU slave/unit address.</param>
|
||||
/// <param name="pdu">The bare PDU (function code + data).</param>
|
||||
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
|
||||
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
|
||||
{
|
||||
var frame = new byte[1 + pdu.Length];
|
||||
frame[0] = unitId;
|
||||
pdu.CopyTo(frame.AsSpan(1));
|
||||
return ModbusCrc.Append(frame);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
|
||||
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
|
||||
/// </summary>
|
||||
/// <param name="stream">The byte stream to read the response from.</param>
|
||||
/// <param name="expectedUnit">
|
||||
/// The unit id the request was addressed to. The response's address byte is validated against
|
||||
/// it after the CRC passes; a mismatch is a unit-mismatch desync.
|
||||
/// </param>
|
||||
/// <param name="ct">A token to cancel the read.</param>
|
||||
/// <returns>The bare response PDU (function code followed by its data).</returns>
|
||||
/// <exception cref="ModbusTransportDesyncException">
|
||||
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
|
||||
/// address byte did not match <paramref name="expectedUnit"/>.
|
||||
/// </exception>
|
||||
/// <exception cref="ModbusException">
|
||||
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
|
||||
/// </exception>
|
||||
public static async Task<byte[]> ReadResponsePduAsync(
|
||||
Stream stream, byte expectedUnit, CancellationToken ct)
|
||||
{
|
||||
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
|
||||
var header = new byte[2];
|
||||
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
|
||||
var fc = header[1];
|
||||
|
||||
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
|
||||
if ((fc & 0x80) != 0)
|
||||
{
|
||||
// Exception frame: excCode(1) + CRC(2).
|
||||
trailing = 1 + 2;
|
||||
}
|
||||
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
|
||||
{
|
||||
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
|
||||
var bc = new byte[1];
|
||||
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
|
||||
var byteCount = bc[0];
|
||||
var rest = new byte[byteCount + 2];
|
||||
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, header, bc, rest);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
|
||||
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
|
||||
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
|
||||
trailing = 4 + 2;
|
||||
}
|
||||
|
||||
var tail = new byte[trailing];
|
||||
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
|
||||
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
|
||||
/// except the CRC bytes, then validates the response address against
|
||||
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
|
||||
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
|
||||
/// </summary>
|
||||
private static byte[] ValidateAndStrip(
|
||||
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
|
||||
{
|
||||
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
|
||||
var frame = new byte[header.Length + middle.Length + tail.Length];
|
||||
header.CopyTo(frame);
|
||||
middle.CopyTo(frame.AsSpan(header.Length));
|
||||
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
|
||||
|
||||
// CRC covers everything except the trailing 2 CRC bytes.
|
||||
var body = frame.AsSpan(0, frame.Length - 2);
|
||||
var expected = ModbusCrc.Compute(body);
|
||||
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
|
||||
if (actual != expected)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.CrcMismatch,
|
||||
$"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
|
||||
|
||||
// Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
|
||||
// failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
|
||||
// RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
|
||||
var addr = frame[0];
|
||||
if (addr != expectedUnit)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.UnitMismatch,
|
||||
$"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
|
||||
|
||||
// Bare PDU = frame minus leading addr and trailing CRC.
|
||||
var pdu = body[1..].ToArray();
|
||||
|
||||
// Exception PDU: high bit set on the function code. The CRC already validated, so this is a
|
||||
// coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
|
||||
if ((pdu[0] & 0x80) != 0)
|
||||
{
|
||||
var fc = (byte)(pdu[0] & 0x7F);
|
||||
var exCode = pdu[1];
|
||||
throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
|
||||
}
|
||||
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills <paramref name="buf"/> completely from <paramref name="stream"/>, throwing a
|
||||
/// truncation desync if the stream ends first. Mirrors
|
||||
/// <c>ModbusTcpTransport.ReadExactlyAsync</c>, but normalises the end-of-stream to a
|
||||
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
|
||||
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
|
||||
/// </summary>
|
||||
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||
if (n == 0)
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
$"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
|
||||
read += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete Modbus RTU-over-TCP transport. Composes <see cref="ModbusSocketLifecycle"/> for the
|
||||
/// connect / reconnect / keep-alive / idle machinery and <see cref="ModbusRtuFraming"/> for the
|
||||
/// wire layout — the same lifecycle <see cref="ModbusTcpTransport"/> uses, but with CRC framing
|
||||
/// instead of MBAP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Delta from <see cref="ModbusTcpTransport"/>: an RTU ADU is <c>[unitId][PDU][CRC]</c> with
|
||||
/// <b>no MBAP header and no transaction id</b>. Because there is no TxId to correlate
|
||||
/// interleaved responses, at most one transaction may be on the bus at a time — the
|
||||
/// <see cref="_gate"/> single-flight is therefore <b>mandatory</b>, not merely a
|
||||
/// diagnostics convenience.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
|
||||
/// failure (<see cref="IOException"/> / <see cref="SocketException"/> /
|
||||
/// <see cref="EndOfStreamException"/>, and the <see cref="ModbusTransportDesyncException"/>
|
||||
/// that derives from <see cref="IOException"/>) triggers a single reconnect-then-retry; a
|
||||
/// second failure bubbles up so the driver's health surface reflects the real state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every transaction runs under a per-op deadline (a linked
|
||||
/// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>, design §7 / R2-01) so a
|
||||
/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
|
||||
/// <see cref="ModbusTransportDesyncException"/> and tears the socket down so the next attempt
|
||||
/// reconnects. A <b>caller</b> cancellation is distinguished from that timeout and propagates
|
||||
/// as a plain <see cref="OperationCanceledException"/> with no teardown.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The constructor is connection-free; all socket I/O happens in <see cref="ConnectAsync"/> /
|
||||
/// <see cref="SendAsync"/>. The <see cref="ForTest"/> seam injects a pre-connected
|
||||
/// <see cref="Stream"/> (an in-memory duplex fake) so the send/receive orchestration,
|
||||
/// single-flight, and deadline can be exercised with no socket — in that path
|
||||
/// <see cref="_life"/> is <see langword="null"/> and the idle / reconnect machinery is
|
||||
/// skipped (a raw injected stream cannot reconnect).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusRtuOverTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly ModbusSocketLifecycle? _life;
|
||||
private readonly Stream? _testStream;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
|
||||
/// <param name="port">The TCP port of the Modbus gateway.</param>
|
||||
/// <param name="timeout">The timeout for socket operations and the per-op response deadline.</param>
|
||||
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
||||
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
||||
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
||||
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
||||
public ModbusRtuOverTcpTransport(
|
||||
string host, int port, TimeSpan timeout, bool autoReconnect = true,
|
||||
ModbusKeepAliveOptions? keepAlive = null,
|
||||
TimeSpan? idleDisconnect = null,
|
||||
ModbusReconnectOptions? reconnect = null)
|
||||
{
|
||||
_timeout = timeout;
|
||||
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||
}
|
||||
|
||||
private ModbusRtuOverTcpTransport(Stream testStream, TimeSpan timeout)
|
||||
{
|
||||
_timeout = timeout;
|
||||
_testStream = testStream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: builds a transport over a pre-connected, in-memory duplex <paramref name="stream"/>,
|
||||
/// bypassing <see cref="ConnectAsync"/> and the idle / reconnect machinery so the send/receive
|
||||
/// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
|
||||
/// </summary>
|
||||
/// <param name="stream">A pre-connected duplex stream standing in for the socket.</param>
|
||||
/// <param name="timeout">The per-op response deadline.</param>
|
||||
/// <returns>A transport driving <paramref name="stream"/> directly.</returns>
|
||||
internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ConnectAsync(CancellationToken ct) =>
|
||||
// The ForTest seam is handed a pre-connected stream — nothing to dial.
|
||||
_life is null ? Task.CompletedTask : _life.ConnectAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
|
||||
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
|
||||
// at a time — serialise every send/receive under the gate.
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
|
||||
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
|
||||
// against silent NAT / firewall reaping.
|
||||
if (_life is not null && _life.ShouldReconnectForIdle())
|
||||
{
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life?.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — a second failure propagates so health/status
|
||||
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
|
||||
// lifecycle to reconnect).
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The live stream: the lifecycle's <see cref="NetworkStream"/>, or the injected test stream.</summary>
|
||||
private Stream? CurrentStream => _life?.Stream ?? _testStream;
|
||||
|
||||
/// <summary>
|
||||
/// Executes exactly one RTU transaction on the current stream: build the ADU, write + flush,
|
||||
/// read back one deframed response PDU. See the class remarks for the desync / timeout /
|
||||
/// caller-cancel handling.
|
||||
/// </summary>
|
||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var stream = CurrentStream;
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
|
||||
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
|
||||
// exception PDU throws ModbusException (socket still coherent — propagates, no teardown);
|
||||
// truncation / CRC / unit-mismatch throw ModbusTransportDesyncException.
|
||||
return await ModbusRtuFraming.ReadResponsePduAsync(stream, unitId, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (ModbusTransportDesyncException)
|
||||
{
|
||||
// Framing violation: the stream is desynchronized — never reuse it.
|
||||
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
|
||||
// and corrupt the next read — tear the socket down and normalise as a desync so the
|
||||
// reconnect retry / status mapping engages.
|
||||
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
|
||||
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
|
||||
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
|
||||
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
|
||||
/// the lifecycle knows nothing about MBAP / RTU wire layout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
|
||||
/// keep-alive / idle behaviour is written once and composed by both transports. The
|
||||
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
|
||||
/// <see cref="ConnectWithBackoffAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
|
||||
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
|
||||
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
|
||||
/// Enabling OS-level <c>SO_KEEPALIVE</c> lets the driver's own side detect a stuck socket
|
||||
/// in reasonable time even when the application is mostly idle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ModbusSocketLifecycle : IAsyncDisposable
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly bool _autoReconnect;
|
||||
private readonly ModbusKeepAliveOptions _keepAlive;
|
||||
private readonly TimeSpan? _idleDisconnect;
|
||||
private readonly ModbusReconnectOptions _reconnect;
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
/// <param name="port">The TCP port of the Modbus server.</param>
|
||||
/// <param name="timeout">The timeout for socket operations.</param>
|
||||
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
|
||||
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
|
||||
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
|
||||
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
|
||||
public ModbusSocketLifecycle(
|
||||
string host, int port, TimeSpan timeout, bool autoReconnect = true,
|
||||
ModbusKeepAliveOptions? keepAlive = null,
|
||||
TimeSpan? idleDisconnect = null,
|
||||
ModbusReconnectOptions? reconnect = null)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
_autoReconnect = autoReconnect;
|
||||
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
|
||||
_idleDisconnect = idleDisconnect;
|
||||
_reconnect = reconnect ?? new ModbusReconnectOptions();
|
||||
}
|
||||
|
||||
/// <summary>Gets the current live <see cref="NetworkStream"/>, or <see langword="null"/> when not connected.</summary>
|
||||
public NetworkStream? Stream => _stream;
|
||||
|
||||
/// <summary>Gets a value indicating whether auto-reconnect on socket failures is enabled.</summary>
|
||||
public bool AutoReconnect => _autoReconnect;
|
||||
|
||||
/// <summary>Establishes a connection to the Modbus server, preferring IPv4.</summary>
|
||||
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous connection operation.</returns>
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client, _keepAlive);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
||||
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
||||
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous reconnect operation.</returns>
|
||||
public async Task ConnectWithBackoffAsync(CancellationToken ct)
|
||||
{
|
||||
var delay = _reconnect.InitialDelay;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
|
||||
{
|
||||
attempt++;
|
||||
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
|
||||
// pathological multipliers / long deployments.
|
||||
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
|
||||
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
|
||||
if (attempt >= 10)
|
||||
{
|
||||
// Bail after 10 attempts to surface persistent failure to the caller. With
|
||||
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
|
||||
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether the socket has been idle longer than the configured idle-disconnect
|
||||
/// threshold and should be proactively torn down + reconnected before the next transaction.
|
||||
/// Always <see langword="false"/> when no idle-disconnect timeout is configured.
|
||||
/// </summary>
|
||||
/// <returns><see langword="true"/> when the idle threshold has been exceeded.</returns>
|
||||
public bool ShouldReconnectForIdle() =>
|
||||
_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value;
|
||||
|
||||
/// <summary>Records that a transaction just succeeded, resetting the idle-disconnect clock.</summary>
|
||||
public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Tears down the current socket + stream, leaving the lifecycle ready to reconnect.</summary>
|
||||
/// <returns>A task representing the asynchronous teardown operation.</returns>
|
||||
public async Task TearDownAsync()
|
||||
{
|
||||
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
_stream = null;
|
||||
try { _client?.Dispose(); } catch { }
|
||||
_client = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
|
||||
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
|
||||
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
|
||||
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
|
||||
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
|
||||
/// application-level probe still detects problems).
|
||||
/// </summary>
|
||||
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
|
||||
{
|
||||
if (!opts.Enabled) return;
|
||||
try
|
||||
{
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
// A TimeSpan < 1s previously truncated to 0 via the int cast,
|
||||
// which Windows / Linux interpret as "use the default" — silently defeating the
|
||||
// configured keep-alive timing. Round up to at least 1 second so a sub-second
|
||||
// configuration still produces a real keep-alive cadence. Negative values are
|
||||
// also clamped to 1 to avoid surfacing as OS errors.
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
|
||||
ClampToWholeSeconds(opts.Time));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
|
||||
ClampToWholeSeconds(opts.Interval));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
|
||||
}
|
||||
catch { /* best-effort; older OSes may not expose the granular knobs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
|
||||
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
|
||||
/// keep-alive timing into "use the default" on most OSes.
|
||||
/// </summary>
|
||||
/// <param name="ts">The timespan to clamp to whole seconds.</param>
|
||||
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
|
||||
internal static int ClampToWholeSeconds(TimeSpan ts)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
|
||||
return seconds < 1 ? 1 : seconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
|
||||
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
|
||||
/// PLC just returned exception 02 Illegal Data Address).
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to classify.</param>
|
||||
/// <returns><see langword="true"/> when the exception is a socket-level failure.</returns>
|
||||
internal static bool IsSocketLevelFailure(Exception ex) =>
|
||||
ex is EndOfStreamException
|
||||
|| ex is IOException
|
||||
|| ex is SocketException
|
||||
|| ex is ObjectDisposedException;
|
||||
|
||||
/// <summary>Asynchronously disposes the underlying socket + stream resources.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,19 @@ using System.Net.Sockets;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
||||
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
|
||||
/// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
|
||||
/// typically support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
|
||||
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
|
||||
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
|
||||
/// reuses with different framing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
|
||||
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
|
||||
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
|
||||
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
/// </remarks>
|
||||
public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly ModbusSocketLifecycle _life;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly bool _autoReconnect;
|
||||
private readonly ModbusKeepAliveOptions _keepAlive;
|
||||
private readonly TimeSpan? _idleDisconnect;
|
||||
private readonly ModbusReconnectOptions _reconnect;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
private DateTime _lastSuccessUtc = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
|
||||
/// <param name="host">The host address or hostname of the Modbus server.</param>
|
||||
@@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
TimeSpan? idleDisconnect = null,
|
||||
ModbusReconnectOptions? reconnect = null)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
_autoReconnect = autoReconnect;
|
||||
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
|
||||
_idleDisconnect = idleDisconnect;
|
||||
_reconnect = reconnect ?? new ModbusReconnectOptions();
|
||||
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
|
||||
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
|
||||
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
|
||||
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
|
||||
// dialing the IPv4 address directly sidesteps that.
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
|
||||
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
|
||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
|
||||
|
||||
_client = new TcpClient(target.AddressFamily);
|
||||
EnableKeepAlive(_client, _keepAlive);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
|
||||
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
|
||||
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
|
||||
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
|
||||
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
|
||||
/// application-level probe still detects problems).
|
||||
/// </summary>
|
||||
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
|
||||
{
|
||||
if (!opts.Enabled) return;
|
||||
try
|
||||
{
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
|
||||
// A TimeSpan < 1s previously truncated to 0 via the int cast,
|
||||
// which Windows / Linux interpret as "use the default" — silently defeating the
|
||||
// configured keep-alive timing. Round up to at least 1 second so a sub-second
|
||||
// configuration still produces a real keep-alive cadence. Negative values are
|
||||
// also clamped to 1 to avoid surfacing as OS errors.
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
|
||||
ClampToWholeSeconds(opts.Time));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
|
||||
ClampToWholeSeconds(opts.Interval));
|
||||
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
|
||||
}
|
||||
catch { /* best-effort; older OSes may not expose the granular knobs */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
|
||||
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
|
||||
/// keep-alive timing into "use the default" on most OSes.
|
||||
/// </summary>
|
||||
/// <param name="ts">The timespan to clamp to whole seconds.</param>
|
||||
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
|
||||
internal static int ClampToWholeSeconds(TimeSpan ts)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
|
||||
return seconds < 1 ? 1 : seconds;
|
||||
}
|
||||
public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
// threshold, tear it down + reconnect before this PDU lands. Defends against silent
|
||||
// NAT / firewall reaping where the socket looks alive locally but the upstream side
|
||||
// dropped it minutes ago.
|
||||
if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value)
|
||||
if (_life.ShouldReconnectForIdle())
|
||||
{
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
|
||||
catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
|
||||
{
|
||||
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
|
||||
// configured), resend. Single retry — if it fails again, let it propagate so
|
||||
// health/status reflect reality.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
|
||||
_lastSuccessUtc = DateTime.UtcNow;
|
||||
_life.MarkSuccess();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect attempt with the configured geometric backoff. The first attempt fires after
|
||||
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
|
||||
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
|
||||
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
|
||||
/// </summary>
|
||||
private async Task ConnectWithBackoffAsync(CancellationToken ct)
|
||||
{
|
||||
var delay = _reconnect.InitialDelay;
|
||||
var attempt = 0;
|
||||
while (true)
|
||||
{
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
|
||||
{
|
||||
attempt++;
|
||||
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
|
||||
// pathological multipliers / long deployments.
|
||||
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
|
||||
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
|
||||
if (attempt >= 10)
|
||||
{
|
||||
// Bail after 10 attempts to surface persistent failure to the caller. With
|
||||
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
|
||||
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes exactly one Modbus transaction on the current socket.
|
||||
/// </summary>
|
||||
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
/// </remarks>
|
||||
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var stream = _life.Stream;
|
||||
if (stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
cts.CancelAfter(_timeout);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||
await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
throw new ModbusTransportDesyncException(
|
||||
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
|
||||
$"Modbus response length too small: {respLen}");
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Exception PDU: function code has high bit set. This is a well-formed protocol-level
|
||||
// error — the socket is still coherent, so it MUST propagate without teardown.
|
||||
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
catch (ModbusTransportDesyncException)
|
||||
{
|
||||
// Framing violation: the socket is desynchronized — never reuse it.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
@@ -288,33 +184,13 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
|
||||
// and corrupt the next read — tear the socket down and normalize as a desync so the
|
||||
// reconnect retry / status mapping engages.
|
||||
await TearDownAsync().ConfigureAwait(false);
|
||||
await _life.TearDownAsync().ConfigureAwait(false);
|
||||
throw new ModbusTransportDesyncException(
|
||||
ModbusTransportDesyncException.DesyncReason.Timeout,
|
||||
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
|
||||
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
|
||||
/// PLC just returned exception 02 Illegal Data Address).
|
||||
/// </summary>
|
||||
private static bool IsSocketLevelFailure(Exception ex) =>
|
||||
ex is EndOfStreamException
|
||||
|| ex is IOException
|
||||
|| ex is SocketException
|
||||
|| ex is ObjectDisposedException;
|
||||
|
||||
private async Task TearDownAsync()
|
||||
{
|
||||
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
_stream = null;
|
||||
try { _client?.Dispose(); } catch { }
|
||||
_client = null;
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
await _life.DisposeAsync().ConfigureAwait(false);
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a Modbus TCP transaction leaves the single-flight socket in an unknown /
|
||||
/// Raised when a Modbus transaction leaves the single-flight socket in an unknown /
|
||||
/// desynchronized state — a per-op response timeout, or a framing violation (transaction-id
|
||||
/// mismatch or truncated MBAP length). Unlike a Modbus <em>exception PDU</em> (a well-formed
|
||||
/// mismatch, truncated frame, RTU CRC mismatch, or RTU unit-address mismatch). Unlike a Modbus
|
||||
/// <em>exception PDU</em> (a well-formed
|
||||
/// protocol-level error where the socket is still coherent and the request must simply
|
||||
/// propagate), a desync means bytes may still be in flight or half-read, so the socket is
|
||||
/// unusable and MUST be torn down before this is thrown.
|
||||
@@ -27,8 +28,20 @@ public sealed class ModbusTransportDesyncException : IOException
|
||||
/// <summary>The response MBAP transaction id did not match the request's.</summary>
|
||||
TxIdMismatch,
|
||||
|
||||
/// <summary>The response MBAP length field was below the mandatory minimum (truncated frame).</summary>
|
||||
/// <summary>
|
||||
/// The frame ended before it was complete — the MBAP length field was below the mandatory
|
||||
/// minimum (TCP), or the stream closed mid-frame while reading a length-less RTU frame.
|
||||
/// </summary>
|
||||
TruncatedFrame,
|
||||
|
||||
/// <summary>The RTU frame's trailing CRC-16 did not validate over the received bytes.</summary>
|
||||
CrcMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// A CRC-valid RTU frame carried a slave/unit address other than the one addressed — on an
|
||||
/// RS-485 multi-drop bus, a reply from the wrong slave.
|
||||
/// </summary>
|
||||
UnitMismatch,
|
||||
}
|
||||
|
||||
/// <summary>Gets the reason the socket was classified desynchronized.</summary>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Single mapping point from <see cref="ModbusDriverOptions"/> to the concrete
|
||||
/// <see cref="IModbusTransport"/> its <see cref="ModbusDriverOptions.Transport"/> selects.
|
||||
/// Consumed by both <see cref="ModbusDriver"/>'s default transport-factory closure and the
|
||||
/// AdminUI Test-Connect probe, so the <see cref="ModbusTransportMode"/> switch lives in
|
||||
/// exactly one place.
|
||||
/// </summary>
|
||||
public static class ModbusTransportFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the transport <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/>
|
||||
/// selects, threading every shared connection setting (Host/Port/Timeout/AutoReconnect/
|
||||
/// KeepAlive/IdleDisconnectTimeout/Reconnect) through to whichever concrete transport is built.
|
||||
/// </summary>
|
||||
/// <param name="options">Driver configuration options.</param>
|
||||
/// <returns>A <see cref="ModbusRtuOverTcpTransport"/> when <see cref="ModbusTransportMode.RtuOverTcp"/>
|
||||
/// is selected; a <see cref="ModbusTcpTransport"/> when <see cref="ModbusTransportMode.Tcp"/> is selected.</returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/> is not a recognized
|
||||
/// <see cref="ModbusTransportMode"/> member — fail loudly rather than silently falling back to TCP/MBAP.
|
||||
/// </exception>
|
||||
public static IModbusTransport Create(ModbusDriverOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return options.Transport switch
|
||||
{
|
||||
ModbusTransportMode.RtuOverTcp => new ModbusRtuOverTcpTransport(
|
||||
options.Host, options.Port, options.Timeout, options.AutoReconnect,
|
||||
keepAlive: options.KeepAlive,
|
||||
idleDisconnect: options.IdleDisconnectTimeout,
|
||||
reconnect: options.Reconnect),
|
||||
ModbusTransportMode.Tcp => new ModbusTcpTransport(
|
||||
options.Host, options.Port, options.Timeout, options.AutoReconnect,
|
||||
keepAlive: options.KeepAlive,
|
||||
idleDisconnect: options.IdleDisconnectTimeout,
|
||||
reconnect: options.Reconnect),
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(options), options.Transport, "Unknown Modbus transport mode."),
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user