Compare commits
121 Commits
898c7365c4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 755ae1aa3f | |||
| 549c656489 | |||
| 28c2866710 | |||
| 154171f48c | |||
| 4dc2ad8084 | |||
| 1919a8e538 | |||
| b95b99ba8e | |||
| ee12568cab | |||
| d5bd4226ee | |||
| 8d9155682d | |||
| 767e7031b3 | |||
| 64ec7aa43a | |||
| 6d7a458c4d | |||
| cd0157a3b8 | |||
| 6917d8805d | |||
| 8538fb798b | |||
| 31a98a1bf5 | |||
| 3fdf24659f | |||
| e4e313cf1c | |||
| 2afef00eaa | |||
| b245f380b1 | |||
| c164ec3da1 | |||
| 2589774480 | |||
| 4ad540376d | |||
| 57a5bb3c60 | |||
| 163dd7ab5c | |||
| 1ae26675ac | |||
| 92ba120964 | |||
| b295b24419 | |||
| 07a4a5ff5e | |||
| 2409d05a28 | |||
| 033b3700c4 | |||
| ff62b62a55 | |||
| a13ae926a8 | |||
| f79d13e2d8 | |||
| 36abd871a1 | |||
| 420692b6e5 | |||
| 211c6ea6d6 | |||
| a856d61510 | |||
| abf3116bd4 | |||
| 4ec01bdfc1 | |||
| d421487bcd | |||
| e27eb77187 | |||
| f2a9ee5d93 | |||
| 7980b34692 | |||
| 585f827ef3 | |||
| be1df2d1e5 | |||
| 9b5a96876e | |||
| 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,19 @@ 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.
|
||||
|
||||
⚠️ **Nothing consumes that signal.** No type under `src/Server/` or `src/Core/` subscribes to
|
||||
`OnRediscoveryNeeded` — the only `+=` handlers in `src/` are Galaxy re-raising its own watcher's event
|
||||
onto its own interface (`GalaxyDriver.cs:586`). `DriverHostActor.HandleDiscoveredNodes`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:986-1002`) short-circuits with an
|
||||
unconditional `return;` — discovered-node injection is **dormant in v3** because raw tags are authored
|
||||
through the `/raw` browse-commit flow instead. A Galaxy redeploy therefore does **not** change the
|
||||
served address space; a config redeploy is required. The same inert-signal gap affects TwinCAT,
|
||||
MQTT/Sparkplug and MTConnect, and `IHostConnectivityProbe` is likewise unconsumed (`GetHostStatuses()`
|
||||
has no production call site; nothing ever writes a `DriverHostStatus` row). Tracked as Gitea **#518**
|
||||
(no consumer) and **#507** (re-migrate injection onto the raw subtree) — note that fixing either alone
|
||||
changes nothing observable.
|
||||
|
||||
## mxaccessgw
|
||||
|
||||
@@ -168,13 +202,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 +245,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 +281,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 +392,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 +502,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 +512,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,8 +75,26 @@
|
||||
<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'
|
||||
hard-coded uint constants against. Referenced by that TEST project only — the drivers
|
||||
themselves stay SDK-free by design and keep spelling status codes as bare uints. -->
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
|
||||
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
|
||||
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
# 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`. Should be corrected alongside #516.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
2. **#518 + #507 together** — neither fix is observable alone.
|
||||
3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
*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"
|
||||
@@ -272,6 +272,12 @@ services:
|
||||
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
# Sql poll driver — the named connection string a Sql driver instance references by
|
||||
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
|
||||
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
|
||||
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
|
||||
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
|
||||
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-1"
|
||||
@@ -391,6 +397,12 @@ services:
|
||||
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
|
||||
ASPNETCORE_URLS: "http://+:9000"
|
||||
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
# Sql poll driver — the named connection string a Sql driver instance references by
|
||||
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
|
||||
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
|
||||
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
|
||||
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
|
||||
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
|
||||
Cluster__Hostname: "0.0.0.0"
|
||||
Cluster__Port: "4053"
|
||||
Cluster__PublicHostname: "central-2"
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Read/Write Operations
|
||||
|
||||
> ⚠️ **Accuracy warning (audited 2026-07-27).** Parts of this page describe v2-era machinery that no
|
||||
> longer exists. Two corrections matter most:
|
||||
>
|
||||
> 1. **There is no per-node ACL gate on the read path — or anywhere else.** `WriteAuthzPolicy`,
|
||||
> `AuthorizationGate`, `NodeScopeResolver` and `AuthorizationBootstrap` have **zero occurrences in
|
||||
> `src/`**. The ACL evaluator that *does* exist (`IPermissionEvaluator` / `TriePermissionEvaluator`
|
||||
> / `PermissionTrieCache`, `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/`) has **no production
|
||||
> consumer** — `OtOpcUaNodeManager` never references it — even though `ClusterAcls.razor` lets
|
||||
> operators author `NodeAcl` rows and `ConfigComposer.cs:51` ships them in every deployment
|
||||
> artifact. Actual enforcement today is LDAP role mapping plus the realm-qualified `WriteOperate`
|
||||
> check in `OtOpcUaNodeManager`, which is a **write** gate; **reads are ungated**. See
|
||||
> `deferment.md` §3.1.
|
||||
> 2. **`GenericDriverNodeManager` is not a production dispatch path** — it is Core test scaffolding
|
||||
> with zero production references (`GenericDriverNodeManager.cs:71`). The live server materialises
|
||||
> the address space through `AddressSpaceComposer` / `AddressSpaceApplier`.
|
||||
>
|
||||
> The `CapabilityInvoker` / Polly / `OnReadValue` / `OnWriteValue` mechanics below remain accurate.
|
||||
|
||||
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.
|
||||
|
||||
## Driver vs virtual dispatch
|
||||
@@ -10,7 +28,7 @@ Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single
|
||||
- `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.
|
||||
|
||||
ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.
|
||||
~~ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.~~ **Not true** — neither type exists; there is no per-node ACL gate. The only authorization applied before the source branch is the LDAP-role write gate (`WriteOperate` / `WriteTune` / `WriteConfigure`, realm-qualified, fail-closed). See the banner at the top of this page.
|
||||
|
||||
## OnReadValue
|
||||
|
||||
@@ -18,7 +36,7 @@ The hook is registered on every `BaseDataVariableState` created by the `IAddress
|
||||
|
||||
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.
|
||||
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.~~ **Never shipped** — neither type exists in `src/`, and no ACL gate is consulted on the read path. Reads are authorized only by the `AccessLevels` bits set at materialization (and `HistoryRead` for history). Authored `NodeAcl` deny rules have **no effect on reads**.
|
||||
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`.
|
||||
|
||||
|
||||
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
|
||||
|
||||
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
|
||||
|
||||
### Post-(re)load condition-node re-assert (#487)
|
||||
|
||||
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
|
||||
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
|
||||
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
|
||||
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
|
||||
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
|
||||
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
|
||||
never come.
|
||||
|
||||
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
|
||||
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
|
||||
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
|
||||
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
|
||||
|
||||
Four properties are load-bearing:
|
||||
|
||||
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
|
||||
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
|
||||
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
|
||||
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
|
||||
machinery — or a future refactor — to mistake for a transition.
|
||||
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
|
||||
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
|
||||
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
|
||||
alarm's display name — and writes nothing at all on the common all-normal deploy.
|
||||
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
|
||||
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
|
||||
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
|
||||
these writes.
|
||||
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
|
||||
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
|
||||
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
|
||||
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
|
||||
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
|
||||
|
||||
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
|
||||
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
|
||||
|
||||
## Source integration
|
||||
|
||||
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
|
||||
|
||||
@@ -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` — ⚠️ raised but **unconsumed**; a Galaxy redeploy does not rebuild the address space ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) |
|
||||
| `IReadable` | `Runtime/GalaxyMxSession.cs` |
|
||||
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
|
||||
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) |
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
# 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` — see [Known limitations](#known-limitations) for why
|
||||
that signal currently has no consumer.
|
||||
|
||||
## 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. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the
|
||||
server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and
|
||||
`IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to
|
||||
`OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver`
|
||||
wiring its own internal `DeployWatcher` sub-component. So a restarted
|
||||
Agent (a changed `instanceId`) leaves a stale address space behind an
|
||||
otherwise-Healthy driver. **This is a pre-existing fleet-wide gap
|
||||
affecting every driver that implements either interface** (eight drivers
|
||||
besides Galaxy), not something specific to MTConnect — this build simply
|
||||
surfaced it again.
|
||||
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` still fires (but is unconsumed — #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
|
||||
|
||||
@@ -271,7 +271,7 @@ public sealed class GatewayQualityMapperTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||
[InlineData(216, 0x00960000u)] // Good_LocalOverride
|
||||
[InlineData(64, 0x40000000u)] // Uncertain
|
||||
[InlineData(0, 0x80000000u)] // Bad
|
||||
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||
@@ -307,7 +307,7 @@ public sealed class SampleMapperTests
|
||||
{
|
||||
var a = new HistorianAggregateSample { Tag = "T", /* Value unset */ EndTime = Ts(2026,1,1,0,0,0) };
|
||||
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Equal(0x809B0000u, snap.StatusCode); // BadNoData
|
||||
Assert.Null(snap.Value);
|
||||
}
|
||||
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
||||
@@ -323,7 +323,7 @@ public sealed class SampleMapperTests
|
||||
- `StatusCode`: `GatewayQualityMapper.Map((byte)s.OpcQuality)` (prefer `opc_quality`; if zero/unset and `quality` carries the OPC-DA byte, fall back to `quality` — match whatever the gateway populates; document the choice).
|
||||
- `SourceTimestampUtc`: `s.Timestamp.ToDateTime()` (UTC kind).
|
||||
- `ServerTimestampUtc`: `DateTime.UtcNow`.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x800E0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
- `SampleMapper.ToAggregateSnapshot(HistorianAggregateSample)` → null aggregate value ⇒ `StatusCode 0x809B0000` (BadNoData), non-null ⇒ Good (`0x00000000`) with the value; `SourceTimestampUtc` ← the bucket end/start timestamp (match the Wonderware `ToAggregateSnapshots` convention — it stamps the bucket timestamp). Provide `IReadOnlyList<>` batch helpers too.
|
||||
|
||||
**Step 4: run, expect PASS.**
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
|
||||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
|
||||
| `CONDITION` | `String` (state word; §3.6) |
|
||||
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x809B0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
|
||||
### 3.4 `IReadable` — `/current`
|
||||
|
||||
|
||||
@@ -588,6 +588,40 @@ SQL Server client works cross-platform, so `Sql` runs on macOS dev too (unlike G
|
||||
`dialect.QuoteIdentifier` (which escapes/rejects embedded quote characters). A table/column string
|
||||
in a `TagConfig` is validated against the live catalog (or an allow-list) before it's ever quoted
|
||||
into text; an unknown identifier rejects the tag (→ `BadNodeIdUnknown`) rather than executing.
|
||||
|
||||
**Implemented (Gitea #496).** `SqlCatalogGate` + `SqlCatalogLoader`, run from
|
||||
`SqlDriver.InitializeAsync` after the liveness check and before the driver reports Healthy. Shape,
|
||||
and the decisions worth knowing before touching it:
|
||||
|
||||
- **The catalog's spelling is substituted, not merely checked.** An accepted identifier is rewritten
|
||||
to the string the catalog returned, so the text quoted into a poll query is one this driver read
|
||||
back out of `ListSchemas`/`ListTables`/`ListColumns` — not operator input. Matching is
|
||||
exact-ordinal first, then a *unique* case-insensitive hit (SQL Server's default collation is CI, so
|
||||
case variants have always worked and rejecting them would break valid configs); an ambiguous CI
|
||||
match under a case-sensitive collation is refused rather than guessed, because picking one would
|
||||
publish another column's data under the operator's node.
|
||||
- **Charset check first, catalog lookup second.** Each identifier goes through `QuoteIdentifier` for
|
||||
its rejection rules *before* being looked up, so a name carrying a control or Unicode format
|
||||
character is rejected **without its value being echoed** into a log line (Trojan-Source). A name
|
||||
that passes is safe to render, which is why catalog-miss messages *do* name it — an operator
|
||||
hunting a typo has to see what they wrote.
|
||||
- **Rejection is per-tag and keeps the node.** The rejected tag is dropped from the *polled* table
|
||||
but stays in the *authored* table, so it still materializes as a node and reads
|
||||
`BadNodeIdUnknown` (§8.1's specified outcome) instead of vanishing from the address space. Every
|
||||
drop is logged at Warning with the tag, the field and the reason.
|
||||
- **A catalog that cannot be read faults Initialize; it does not reject every tag.** An unreadable
|
||||
catalog is the *absence* of evidence about the tags, not evidence against them — rejecting all of
|
||||
them would serve a confidently-empty address space and send the operator hunting typos that do not
|
||||
exist. Zero visible schemas is treated the same way, because that is what a missing GRANT looks
|
||||
like. Faulting lands `DriverInstanceActor` in Reconnecting with its retry timer running.
|
||||
- **Bounded load:** one schema-list query, one default-schema scalar (`ISqlDialect.DefaultSchemaSql`,
|
||||
added for this — an unqualified `TagValues` must resolve in whatever schema the *server* says, not
|
||||
a guessed `dbo`), then one `ListTables` per distinct authored schema and one `ListColumns` per
|
||||
distinct authored relation. It never enumerates the whole catalog, and the load runs under the same
|
||||
wall-clock bound as the liveness check (R2-01 / STAB-14).
|
||||
- **Accepted v1 limitation:** a 3-part `db.schema.table` (or a linked-server name) addresses a
|
||||
catalog this connection cannot enumerate, so it cannot be allow-listed and is rejected with a
|
||||
message pointing at the fix — expose the data through a view in the connected database.
|
||||
- **Treat any code path that builds SQL by string-concatenating a tag field as a defect** — enforce
|
||||
with a review checklist item + a unit test that feeds a malicious `keyValue`/`table`
|
||||
(`'; DROP TABLE …`) and asserts it either binds harmlessly (value) or is rejected (identifier),
|
||||
@@ -607,8 +641,15 @@ comment) and the env-overridable ConfigDb connection string:
|
||||
file. Direct env read (not `IConfiguration`) is deliberate: the factory registry materializes
|
||||
drivers via a static `(id, json)` closure (Modbus pattern) with no `IConfiguration` in reach, so
|
||||
this keeps the factory shape unchanged.
|
||||
- If an inline `connectionString` is ever permitted (dev convenience only), the AdminUI **redacts** it
|
||||
in display/logging and flags it dev-only.
|
||||
- **An inline `connectionString` is not permitted at all** — the earlier "dev convenience, redacted in
|
||||
the UI" carve-out is withdrawn (Gitea #498). Redaction only hides a credential that has *already been
|
||||
written* to the config DB and replicated to every node's artifact cache. `SqlDriverConfigDto` has no
|
||||
such property and `UnmappedMemberHandling.Skip` drops the key on read, but that protects only the read
|
||||
path; config blobs are schemaless JSON columns, so nothing stopped the key being **written**.
|
||||
`DraftValidator.ValidateSqlConnectionStringNotPersisted` now fails the deploy
|
||||
(`SqlConnectionStringPersisted`) for a `connectionString` key at the top level of a Sql driver's
|
||||
`DriverConfig` **or** of the `DeviceConfig` of any device beneath it (the two are merged before the DTO
|
||||
sees them). The key is matched case-insensitively, and the error text never echoes the value.
|
||||
- **Never log the resolved connection string.** Log only provider + server host + database name.
|
||||
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
|
||||
Kerberos) so no password transits config at all.
|
||||
|
||||
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
|
||||
|
||||
## Documented follow-ups (non-blocking)
|
||||
|
||||
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.**
|
||||
- **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
|
||||
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
|
||||
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
|
||||
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
|
||||
(a projection also carries the definition's severity, the resolved message template, and the last-observed
|
||||
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
|
||||
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
|
||||
nodes at all. The original deferral text follows for context.
|
||||
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
|
||||
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
|
||||
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,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.
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -41,9 +41,77 @@ public static class DraftValidator
|
||||
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
||||
ValidateEquipReferenceResolution(draft, errors);
|
||||
ValidateCalculationTags(draft, errors);
|
||||
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
||||
/// at the deploy gate (Gitea #498).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
|
||||
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
|
||||
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
|
||||
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
|
||||
/// deserialization. Nothing stopped the key being <b>written</b>. Config blobs are schemaless JSON
|
||||
/// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON
|
||||
/// textarea, so an operator pasting <c>{"connectionString":"Server=…;Password=…"}</c> would put a live
|
||||
/// 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
|
||||
/// device blob lands in exactly the same place.</para>
|
||||
/// <para>The key is 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 rule exists to catch.</para>
|
||||
/// <para><b>The message never echoes the value.</b> Validation errors reach the AdminUI, the deploy
|
||||
/// log and the audit trail; repeating the offending string there would leak the very credential the
|
||||
/// rule is refusing to store.</para>
|
||||
/// </remarks>
|
||||
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
|
||||
|
||||
var sqlInstanceIds = draft.DriverInstances
|
||||
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
||||
.Select(d => d.DriverInstanceId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
if (sqlInstanceIds.Count == 0) return;
|
||||
|
||||
foreach (var d in draft.DriverInstances)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) 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 " +
|
||||
"from the environment / secret store at Initialize; a literal connection string here would be " +
|
||||
"stored in the config database and replicated to every node, and the runtime ignores it anyway. " +
|
||||
"Remove the key and set 'connectionStringRef'.",
|
||||
d.DriverInstanceId));
|
||||
}
|
||||
|
||||
foreach (var dev in draft.Devices)
|
||||
{
|
||||
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) 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 " +
|
||||
"the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.",
|
||||
dev.DeviceId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,14 @@ public static class DriverTypeNames
|
||||
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
|
||||
public const string Calculation = "Calculation";
|
||||
|
||||
/// <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
|
||||
/// the full set (e.g. validation of an authored <c>DriverType</c>).
|
||||
@@ -68,5 +76,8 @@ public static class DriverTypeNames
|
||||
OpcUaClient,
|
||||
Galaxy,
|
||||
Calculation,
|
||||
Sql,
|
||||
Mqtt,
|
||||
MTConnect,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
|
||||
/// severity, the rendered message template and the last-observed input quality, i.e. everything
|
||||
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
|
||||
/// the next transition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
|
||||
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
|
||||
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
|
||||
/// transition to report), so the transition stream cannot carry the current state — that is
|
||||
/// exactly why the caller needs this. Routing these through the emission path instead would
|
||||
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
|
||||
/// intentionally one the emission machinery cannot consume.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
|
||||
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
|
||||
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
|
||||
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
|
||||
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
|
||||
/// read publishes its own transition through the normal path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
|
||||
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
|
||||
{
|
||||
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
|
||||
foreach (var (alarmId, state) in _alarms)
|
||||
{
|
||||
projections.Add(new ScriptedAlarmProjection(
|
||||
AlarmId: alarmId,
|
||||
Severity: state.Definition.Severity,
|
||||
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
|
||||
Condition: state.Condition,
|
||||
WorstInputStatusCode: LastWorstStatus(alarmId)));
|
||||
}
|
||||
|
||||
return projections;
|
||||
}
|
||||
|
||||
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the acknowledgment.</param>
|
||||
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
|
||||
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
|
||||
uint WorstInputStatusCode = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
|
||||
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
|
||||
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
|
||||
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
|
||||
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
|
||||
/// for a future refactor to mistake for a transition.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
|
||||
/// <param name="Severity">The definition's severity bucket.</param>
|
||||
/// <param name="Message">The message template resolved against the current value cache.</param>
|
||||
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
|
||||
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
|
||||
public sealed record ScriptedAlarmProjection(
|
||||
string AlarmId,
|
||||
AlarmSeverity Severity,
|
||||
string Message,
|
||||
AlarmConditionState Condition,
|
||||
uint WorstInputStatusCode);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
/// engine's so Stream G can compose them behind one driver bridge.
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -44,7 +44,7 @@ public static class AbCipStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The CIP general-status byte value.</param>
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public static class AbLegacyStatusMapper
|
||||
{
|
||||
public const uint Good = 0u;
|
||||
public const uint GoodMoreData = 0x00A70000u;
|
||||
public const uint GoodMoreData = 0x00A60000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
public const uint BadNodeIdUnknown = 0x80340000u;
|
||||
public const uint BadNotWritable = 0x803B0000u;
|
||||
@@ -19,7 +19,7 @@ public static class AbLegacyStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class FocasStatusMapper
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
|
||||
|
||||
@@ -856,7 +856,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
rejectedTcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x80000000u, // Bad
|
||||
StatusCode: StatusCodeMap.Bad,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
@@ -875,7 +875,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
tcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: 0x800B0000u, // BadTimeout
|
||||
StatusCode: StatusCodeMap.BadTimeout,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
@@ -26,14 +26,23 @@ internal static class StatusCodeMap
|
||||
{
|
||||
// OPC UA Part 4 standard StatusCodes — top-byte categories are 0x00 (Good),
|
||||
// 0x40 (Uncertain), 0x80 (Bad). Specific codes layer onto the category byte.
|
||||
//
|
||||
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
|
||||
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
|
||||
// originally written as though the DA byte could be shifted into the substatus position, which
|
||||
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
|
||||
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
|
||||
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
|
||||
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
|
||||
// against the pinned SDK's Opc.Ua.StatusCodes.
|
||||
|
||||
public const uint Good = 0x00000000u;
|
||||
public const uint GoodLocalOverride = 0x00D80000u;
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
public const uint UncertainLastUsableValue = 0x40A40000u;
|
||||
public const uint UncertainSensorNotAccurate = 0x408D0000u;
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x408E0000u;
|
||||
public const uint UncertainSubNormal = 0x408F0000u;
|
||||
public const uint UncertainLastUsableValue = 0x40900000u;
|
||||
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
||||
public const uint UncertainSubNormal = 0x40950000u;
|
||||
public const uint Bad = 0x80000000u;
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
public const uint BadNotConnected = 0x808A0000u;
|
||||
@@ -44,6 +53,14 @@ internal static class StatusCodeMap
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
public const uint BadInternalError = 0x80020000u;
|
||||
|
||||
/// <summary>
|
||||
/// Fills a still-pending read when the caller's token fires before the gateway answers. Named here
|
||||
/// rather than written inline at the call site so <c>StatusCodeParityTests</c> can reflect over it —
|
||||
/// an inline literal is invisible to that guard, which is exactly how this constant spent its life
|
||||
/// as <c>0x800B0000</c> (<c>BadServiceUnsupported</c>) under a <c>// BadTimeout</c> comment.
|
||||
/// </summary>
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a raw OPC DA quality byte (the low byte of an OPC DA <c>OpcQuality</c> ushort,
|
||||
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
|
||||
|
||||
+18
-18
@@ -18,29 +18,29 @@ internal static class GatewayQualityMapper
|
||||
public static uint Map(byte q) => q switch
|
||||
{
|
||||
// Good family (192+)
|
||||
192 => 0x00000000u, // Good
|
||||
216 => 0x00D80000u, // Good_LocalOverride
|
||||
192 => HistorianStatusCodes.Good,
|
||||
216 => HistorianStatusCodes.GoodLocalOverride,
|
||||
|
||||
// Uncertain family (64-191)
|
||||
64 => 0x40000000u, // Uncertain
|
||||
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||
88 => 0x40950000u, // Uncertain_SubNormal
|
||||
64 => HistorianStatusCodes.Uncertain,
|
||||
68 => HistorianStatusCodes.UncertainLastUsableValue,
|
||||
80 => HistorianStatusCodes.UncertainSensorNotAccurate,
|
||||
84 => HistorianStatusCodes.UncertainEngineeringUnitsExceeded,
|
||||
88 => HistorianStatusCodes.UncertainSubNormal,
|
||||
|
||||
// Bad family (0-63)
|
||||
0 => 0x80000000u, // Bad
|
||||
4 => 0x80890000u, // Bad_ConfigurationError
|
||||
8 => 0x808A0000u, // Bad_NotConnected
|
||||
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||
16 => 0x808C0000u, // Bad_SensorFailure
|
||||
20 => 0x80050000u, // Bad_CommunicationError
|
||||
24 => 0x808D0000u, // Bad_OutOfService
|
||||
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||
0 => HistorianStatusCodes.Bad,
|
||||
4 => HistorianStatusCodes.BadConfigurationError,
|
||||
8 => HistorianStatusCodes.BadNotConnected,
|
||||
12 => HistorianStatusCodes.BadDeviceFailure,
|
||||
16 => HistorianStatusCodes.BadSensorFailure,
|
||||
20 => HistorianStatusCodes.BadCommunicationError,
|
||||
24 => HistorianStatusCodes.BadOutOfService,
|
||||
32 => HistorianStatusCodes.BadWaitingForInitialData,
|
||||
|
||||
// Unknown — fall back to category bucket so callers still get something usable.
|
||||
_ when q >= 192 => 0x00000000u,
|
||||
_ when q >= 64 => 0x40000000u,
|
||||
_ => 0x80000000u,
|
||||
_ when q >= 192 => HistorianStatusCodes.Good,
|
||||
_ when q >= 64 => HistorianStatusCodes.Uncertain,
|
||||
_ => HistorianStatusCodes.Bad,
|
||||
};
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// The OPC UA status codes this driver publishes, as named constants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The driver layer is deliberately free of an OPC UA SDK reference, so status codes are spelled
|
||||
/// as bare <c>uint</c>s. The cost of that is a value nobody checks against the name it is written
|
||||
/// under — the defect class Gitea #497 found in six places across four drivers, one of them the
|
||||
/// <c>BadNoData</c> in <see cref="SampleMapper"/> that was really <c>BadServerHalted</c>.</para>
|
||||
/// <para><b>Named, not inline, on purpose.</b> <c>StatusCodeParityTests</c> guards these by reflecting
|
||||
/// over <c>const uint</c> fields and comparing each against <c>Opc.Ua.StatusCodes</c> in the pinned SDK.
|
||||
/// A literal written at a call site is invisible to that guard, which is exactly how
|
||||
/// <see cref="GatewayQualityMapper"/> kept an incorrect <c>Good_LocalOverride</c> through every test it
|
||||
/// had. Add a constant here rather than a literal in a <c>switch</c> arm.</para>
|
||||
/// </remarks>
|
||||
internal static class HistorianStatusCodes
|
||||
{
|
||||
// ---- Good family ----
|
||||
|
||||
/// <summary>The value is good; no qualification.</summary>
|
||||
public const uint Good = 0x00000000u;
|
||||
|
||||
/// <summary>The value has been overridden locally (OPC DA quality 216).</summary>
|
||||
public const uint GoodLocalOverride = 0x00960000u;
|
||||
|
||||
// ---- Uncertain family ----
|
||||
|
||||
/// <summary>The value is uncertain; no specific reason.</summary>
|
||||
public const uint Uncertain = 0x40000000u;
|
||||
|
||||
/// <summary>Communication has failed; the last known value is returned (OPC DA quality 68).</summary>
|
||||
public const uint UncertainLastUsableValue = 0x40900000u;
|
||||
|
||||
/// <summary>The sensor is known not to be accurate (OPC DA quality 80).</summary>
|
||||
public const uint UncertainSensorNotAccurate = 0x40930000u;
|
||||
|
||||
/// <summary>The value is outside the engineering-unit range for the sensor (OPC DA quality 84).</summary>
|
||||
public const uint UncertainEngineeringUnitsExceeded = 0x40940000u;
|
||||
|
||||
/// <summary>The value is derived from fewer sources than required (OPC DA quality 88).</summary>
|
||||
public const uint UncertainSubNormal = 0x40950000u;
|
||||
|
||||
// ---- Bad family ----
|
||||
|
||||
/// <summary>The value is bad; no specific reason.</summary>
|
||||
public const uint Bad = 0x80000000u;
|
||||
|
||||
/// <summary>A configuration problem prevents the value being produced (OPC DA quality 4).</summary>
|
||||
public const uint BadConfigurationError = 0x80890000u;
|
||||
|
||||
/// <summary>The source is not connected (OPC DA quality 8).</summary>
|
||||
public const uint BadNotConnected = 0x808A0000u;
|
||||
|
||||
/// <summary>The device reported a failure (OPC DA quality 12).</summary>
|
||||
public const uint BadDeviceFailure = 0x808B0000u;
|
||||
|
||||
/// <summary>The sensor reported a failure (OPC DA quality 16).</summary>
|
||||
public const uint BadSensorFailure = 0x808C0000u;
|
||||
|
||||
/// <summary>Communication with the source failed (OPC DA quality 20).</summary>
|
||||
public const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
/// <summary>The source is out of service (OPC DA quality 24).</summary>
|
||||
public const uint BadOutOfService = 0x808D0000u;
|
||||
|
||||
/// <summary>No initial value has arrived from the source yet (OPC DA quality 32).</summary>
|
||||
public const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
/// <summary>
|
||||
/// The historian returned no data for the requested tag/interval. Distinct from a transport
|
||||
/// failure: the query succeeded and the answer was empty.
|
||||
/// </summary>
|
||||
public const uint BadNoData = 0x809B0000u;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
/// </summary>
|
||||
internal static class SampleMapper
|
||||
{
|
||||
private const uint StatusGood = 0x00000000u;
|
||||
private const uint StatusBadNoData = 0x800E0000u;
|
||||
private const uint StatusGood = HistorianStatusCodes.Good;
|
||||
private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
|
||||
|
||||
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||||
private const byte GoodQualityFloor = 192;
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,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 +108,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) =>
|
||||
|
||||
@@ -74,6 +74,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
|
||||
@@ -159,6 +161,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
@@ -0,0 +1,192 @@
|
||||
using System.Buffers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MQTTnet;
|
||||
using MQTTnet.Protocol;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
|
||||
|
||||
/// <summary>
|
||||
/// Bespoke MQTT address-picker browser: opens a short-lived, <b>strictly passive</b> observation
|
||||
/// window against the broker named by the form's JSON.
|
||||
/// <para>
|
||||
/// MQTT has no discovery protocol, so there is nothing to enumerate — the browser subscribes
|
||||
/// to a wildcard (<c>#</c> / <c>{topicPrefix}/#</c> in plain mode, <c>spBv1.0/{groupId}/#</c>
|
||||
/// in Sparkplug mode) and lets <see cref="MqttBrowseSession"/> accumulate whatever arrives:
|
||||
/// a topic segment tree in plain mode, a Group → EdgeNode → Device → Metric tree decoded from
|
||||
/// observed birth certificates in Sparkplug mode. Nothing on any browse path publishes — in
|
||||
/// either mode: an operator opening a picker must not be able to inject a message into a
|
||||
/// running plant. The session's operator-triggered <c>RequestRebirthAsync</c> is the sole
|
||||
/// sanctioned publish, and it is never reached by <c>OpenAsync</c> or by any browse call.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Layering note.</b> Unlike every other bespoke browser here, this project references the
|
||||
/// runtime <c>.Driver</c> project (to reuse <c>MqttConnection.BuildClientOptions</c> rather
|
||||
/// than fork the TLS / CA-pin path). That is a known, deliberate exception with a documented
|
||||
/// cost and a documented clean fix — see the comment on that <c>ProjectReference</c> in
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj</c> before copying this project as a
|
||||
/// template.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MqttDriverBrowser : IDriverBrowser
|
||||
{
|
||||
/// <summary>Floor on the open budget — a 1 s <c>ConnectTimeoutSeconds</c> would make browse unusable.</summary>
|
||||
internal const int MinOpenBudgetSeconds = 5;
|
||||
|
||||
/// <summary>Ceiling on the open budget — the picker must never hang on an unreachable broker.</summary>
|
||||
internal const int MaxOpenBudgetSeconds = 30;
|
||||
|
||||
/// <summary>Marks the transient browse identity in the broker's client-id/session logs.</summary>
|
||||
internal const string BrowseClientIdPrefix = "-browse-";
|
||||
|
||||
private readonly ILogger<MqttDriverBrowser> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a browser. <b>Connection-free by contract</b> — the universal browser's
|
||||
/// <c>CanBrowse</c> constructs a throwaway instance purely to ask which driver type it
|
||||
/// handles, so the constructor must never touch the network.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>. Never receives credentials.</param>
|
||||
public MqttDriverBrowser(ILogger<MqttDriverBrowser>? logger = null) =>
|
||||
_logger = logger ?? NullLogger<MqttDriverBrowser>.Instance;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Mqtt;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Connects with a browse-only client id, subscribes the wildcard at QoS 0, and hands back a
|
||||
/// session that serves whatever the subscription observes. The whole open is bounded by
|
||||
/// <see cref="OpenBudget"/>; nothing here publishes.
|
||||
/// </remarks>
|
||||
public async Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// MqttJson.Options — the ONE shared instance across factory / probe / driver / browser
|
||||
// (see its remarks). Parsing the same DriverConfig blob through a second, divergent
|
||||
// JsonSerializerOptions is this repo's documented systemic enum bug: the picker would accept
|
||||
// a `mode` / `protocolVersion` spelling the deployed driver rejects, or vice versa.
|
||||
var opts = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, MqttJson.Options)
|
||||
?? throw new InvalidOperationException("Mqtt options deserialized to null.");
|
||||
|
||||
var filter = BuildRootFilter(opts);
|
||||
var suffix = BuildBrowseClientIdSuffix();
|
||||
var clientOptions = MqttConnection.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger);
|
||||
|
||||
using var openCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
openCts.CancelAfter(OpenBudget(opts));
|
||||
|
||||
var client = new MqttClientFactory().CreateMqttClient();
|
||||
var session = new MqttBrowseSession(opts.Mode, client, _logger);
|
||||
try
|
||||
{
|
||||
// Runs on MQTTnet's dispatcher: record and return, nothing else.
|
||||
client.ApplicationMessageReceivedAsync += args =>
|
||||
{
|
||||
var payload = args.ApplicationMessage.Payload;
|
||||
ReadOnlySpan<byte> body = payload.IsSingleSegment ? payload.FirstSpan : payload.ToArray();
|
||||
session.Observe(args.ApplicationMessage.Topic, body);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await client.ConnectAsync(clientOptions, openCts.Token).ConfigureAwait(false);
|
||||
|
||||
// QoS 0: a transient observation window has no delivery guarantee to offer and should
|
||||
// cost the broker as little as possible.
|
||||
var subscribeOptions = new MqttClientSubscribeOptionsBuilder()
|
||||
.WithTopicFilter(f => f
|
||||
.WithTopic(filter)
|
||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce))
|
||||
.Build();
|
||||
await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI MQTT browse session opened against {Host}:{Port} in {Mode} mode observing "
|
||||
+ "'{Filter}' (read-only).",
|
||||
opts.Host, opts.Port, opts.Mode, filter);
|
||||
|
||||
return session;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await session.DisposeAsync().ConfigureAwait(false); // owns the client — disconnects + disposes
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The wildcard the observation window subscribes to.
|
||||
/// <para>
|
||||
/// <b>Plain mode:</b> the whole broker (<c>#</c>) unless the config scopes it with a
|
||||
/// topic prefix.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sparkplug mode:</b> the Sparkplug namespace only —
|
||||
/// <c>spBv1.0/{groupId}/#</c>, matching the group the deployed driver itself subscribes
|
||||
/// (design §3.1), or <c>spBv1.0/#</c> when no group has been authored yet, so the picker
|
||||
/// can discover which groups exist. The Plain-mode topic prefix is deliberately ignored:
|
||||
/// it is a different mode's key, and honouring it would silently narrow the window to a
|
||||
/// prefix that cannot match a Sparkplug topic at all.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The MQTT topic filter to subscribe.</returns>
|
||||
internal static string BuildRootFilter(MqttDriverOptions options)
|
||||
{
|
||||
if (options.Mode == MqttMode.SparkplugB)
|
||||
{
|
||||
var groupId = (options.Sparkplug?.GroupId ?? string.Empty).Trim();
|
||||
return groupId.Length == 0 ? "spBv1.0/#" : $"spBv1.0/{groupId}/#";
|
||||
}
|
||||
|
||||
var prefix = (options.Plain?.TopicPrefix ?? string.Empty).Trim();
|
||||
if (prefix.Length == 0) return "#";
|
||||
if (!prefix.EndsWith('/')) prefix += "/";
|
||||
return prefix + "#";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A unique per-session client-id suffix. A broker disconnects an existing client when a new
|
||||
/// one CONNECTs with the <i>same</i> client id, so sharing the driver's id would knock the
|
||||
/// live driver offline every time an operator opened the picker.
|
||||
/// </summary>
|
||||
/// <returns>The suffix appended to the configured client id.</returns>
|
||||
internal static string BuildBrowseClientIdSuffix() =>
|
||||
BrowseClientIdPrefix + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
/// <summary>
|
||||
/// Projects the authored options into the ones the browse CONNECT uses. Clean session is
|
||||
/// forced: a transient browse identity must not leave queued-message state behind on the
|
||||
/// broker after the picker closes.
|
||||
/// <para>
|
||||
/// ⚠ <b>THE landmine — a Last Will would break the read-only guarantee from outside it.</b>
|
||||
/// <c>MqttDriverOptions</c> (and <c>MqttSparkplugOptions</c>) carry no will, <b>re-verified
|
||||
/// when Task 23 unsealed Sparkplug browse</b>, so there is nothing to strip and the
|
||||
/// projection stays a single <c>CleanSession</c> override. If a will is ever added — NDEATH
|
||||
/// <i>is</i> a will message — it MUST be cleared here: a will is published by the
|
||||
/// <i>broker</i>, not by us, so it is invisible to
|
||||
/// <see cref="MqttBrowseSession.PublishCountForTest"/>, and any ungraceful end to a browse
|
||||
/// session (crash, dropped link, the disconnect deadline expiring) would then fire an
|
||||
/// NDEATH under the plant's own edge-node identity — killing a live Sparkplug node because
|
||||
/// an operator opened an address picker. <c>MqttBrowseSessionTests</c>'
|
||||
/// <c>MqttDriverOptions_CarryNothingWillShaped_…</c> is the reflection guard that turns that
|
||||
/// "there is nothing to strip" into a checked fact rather than a claim.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="options">The authored configuration.</param>
|
||||
/// <returns>The configuration the browse CONNECT is built from.</returns>
|
||||
internal static MqttDriverOptions ToBrowseOptions(MqttDriverOptions options) =>
|
||||
options with { CleanSession = true };
|
||||
|
||||
/// <summary>
|
||||
/// Whole-open bound (connect + subscribe), clamped to
|
||||
/// <see cref="MinOpenBudgetSeconds"/>–<see cref="MaxOpenBudgetSeconds"/> around the config's
|
||||
/// own connect deadline.
|
||||
/// </summary>
|
||||
/// <param name="options">The browse configuration.</param>
|
||||
/// <returns>The clamped open budget.</returns>
|
||||
internal static TimeSpan OpenBudget(MqttDriverOptions options) =>
|
||||
TimeSpan.FromSeconds(Math.Clamp(options.ConnectTimeoutSeconds, MinOpenBudgetSeconds, MaxOpenBudgetSeconds));
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<!--
|
||||
┌──────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ KNOWN, DELIBERATE EXCEPTION to the .Browser → .Contracts pattern. │
|
||||
│ DO NOT COPY THIS LINE INTO A NEW DRIVER'S BROWSER WITHOUT READING THIS. │
|
||||
└──────────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Every other bespoke browser in this repo (OpcUaClient, Galaxy) references only its own
|
||||
.Contracts project plus its transport package. This one also references the runtime
|
||||
.Driver project. That is a real widening, not a free one, and it was accepted knowingly:
|
||||
|
||||
WHY: the browse CONNECT reuses MqttConnection.BuildClientOptions, which owns the TLS
|
||||
posture, the PEM CA-pin chain validator, the credential handling and the protocol
|
||||
version mapping (~100 lines). A second copy here would be a silent security
|
||||
divergence — browse quietly not honouring a pinned CA while the runtime driver does.
|
||||
The method is pure (no I/O, no network), so the dependency costs nothing at run time.
|
||||
|
||||
WHAT IT COSTS: the AdminUI's project graph does NOT otherwise include Core.csproj
|
||||
(only Host.csproj and the runtime Driver.* projects do). Referencing this browser
|
||||
from the AdminUI therefore pulls in Core + Polly.Core + Serilog at build/deploy time.
|
||||
|
||||
THE CLEAN FIX (deferred, not blocked): a small leaf project — say
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Transport (net10, refs .Contracts + MQTTnet) — holding
|
||||
BuildClientOptions/ConfigureTls/the CA-pin helpers, referenced by BOTH .Driver and
|
||||
.Browser. Note the obvious-looking alternative does NOT work: those members cannot
|
||||
move into .Contracts, because .Contracts is deliberately transport-free (Task 1
|
||||
removed its MQTTnet reference to keep it so) and BuildClientOptions returns
|
||||
MqttClientOptions, an MQTTnet type.
|
||||
-->
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MQTTnet"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// MQTT / Sparkplug B driver configuration. Bound from <c>DriverConfig</c> JSON at
|
||||
/// driver-host registration time. Models the settings documented in
|
||||
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A record (not a plain class) so a future secret-resolution seam can produce a
|
||||
/// credential-resolved copy with a <c>with</c> expression — mirrors
|
||||
/// <c>OpcUaClientDriverOptions</c>. <see cref="Mode"/> selects the ingest shape;
|
||||
/// <see cref="Sparkplug"/> and <see cref="Plain"/> are nullable sub-objects — only the one
|
||||
/// matching <see cref="Mode"/> is populated.
|
||||
/// </remarks>
|
||||
public sealed record MqttDriverOptions
|
||||
{
|
||||
/// <summary>Broker hostname or IP address.</summary>
|
||||
public string Host { get; init; } = "localhost";
|
||||
|
||||
/// <summary>Broker TCP port.</summary>
|
||||
[Range(1, 65535)]
|
||||
public int Port { get; init; } = 8883;
|
||||
|
||||
/// <summary>
|
||||
/// MQTT client identifier sent at CONNECT. Leave unset to let the driver generate one
|
||||
/// (a stable per-instance id is recommended so broker-side ACLs / session state persist
|
||||
/// across reconnects).
|
||||
/// </summary>
|
||||
public string? ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, connect over TLS. Default <c>true</c> — this driver never ships an
|
||||
/// anonymous/plaintext-by-default posture; a deployment must opt into <c>false</c> for an
|
||||
/// on-prem/dev broker with no TLS listener.
|
||||
/// </summary>
|
||||
public bool UseTls { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, accept any self-signed / untrusted broker certificate. Dev/on-prem
|
||||
/// escape hatch only — mirrors the <c>ServerHistorian</c> TLS knobs. Must stay
|
||||
/// <c>false</c> in production so MITM attacks against the broker connection fail closed.
|
||||
/// </summary>
|
||||
public bool AllowUntrustedServerCertificate { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// PEM CA file pinning the broker's TLS chain. <c>null</c>/empty uses the OS trust
|
||||
/// store.
|
||||
/// </summary>
|
||||
public string? CaCertificatePath { get; init; }
|
||||
|
||||
/// <summary>Username for broker authentication. <c>null</c> connects without a username.</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for broker authentication. Blank in committed JSON — supply via env, never
|
||||
/// commit or log.
|
||||
/// </summary>
|
||||
public string? Password { get; init; } = "";
|
||||
|
||||
/// <summary>MQTT protocol version to negotiate at CONNECT.</summary>
|
||||
public MqttProtocolVersion ProtocolVersion { get; init; } = MqttProtocolVersion.V500;
|
||||
|
||||
/// <summary>Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.</summary>
|
||||
public bool CleanSession { get; init; } = true;
|
||||
|
||||
/// <summary>Keep-alive interval sent at CONNECT.</summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int KeepAliveSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>Bounded connect deadline (see design §8) — the driver never hangs past this.</summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int ConnectTimeoutSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>Initial reconnect backoff after a connection drop.</summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int ReconnectMinBackoffSeconds { get; init; } = 1;
|
||||
|
||||
/// <summary>Cap on the exponential reconnect backoff.</summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int ReconnectMaxBackoffSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Ceiling on an inbound message body, in bytes. A larger message is refused before any
|
||||
/// decode or parse and degrades its own tags to <c>BadDecodingError</c>. Default 1 MiB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Decode and parse both run synchronously on MQTTnet's shared dispatcher thread, so their
|
||||
/// cost is paid by <i>every</i> subscription, not just the offending topic — without a bound,
|
||||
/// one publisher shipping a multi-megabyte body stalls the whole driver's delivery. "Unbounded"
|
||||
/// is deliberately not offered; a non-positive value falls back to the driver's own 1 MiB
|
||||
/// default. The literal is duplicated from <c>MqttSubscriptionManager.DefaultMaxPayloadBytes</c>
|
||||
/// because this <c>.Contracts</c> assembly is <i>referenced by</i> the driver assembly and
|
||||
/// cannot reference it back; the manager's constructor is the single enforcement point.
|
||||
/// </remarks>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int MaxPayloadBytes { get; init; } = 1024 * 1024;
|
||||
|
||||
/// <summary>Selects the ingest shape — Plain topics or Sparkplug B.</summary>
|
||||
public MqttMode Mode { get; init; } = MqttMode.Plain;
|
||||
|
||||
/// <summary>
|
||||
/// The cluster's authored raw MQTT tags, as delivered by the deploy artifact: each carries the
|
||||
/// tag's <b>RawPath</b> (its v3 identity and driver wire reference) plus its <c>TagConfig</c>
|
||||
/// blob. <see cref="MqttDriver"/> maps each through <see cref="MqttTagDefinitionFactory"/> at
|
||||
/// initialize and re-registers the set <b>wholesale</b> on every reinitialize, so a redeploy
|
||||
/// that drops a tag stops feeding it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is also the <b>only</b> source of the driver's discoverable node set — plain MQTT has
|
||||
/// no browsable address space, and a tag that is not authored here is never materialized no
|
||||
/// matter how much traffic its topic carries. Mirrors <c>ModbusDriverOptions.RawTags</c> /
|
||||
/// <c>FocasDriverOptions.RawTags</c>.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug-only settings. Populated when <see cref="Mode"/> is
|
||||
/// <see cref="MqttMode.SparkplugB"/>; <c>null</c> in Plain mode.
|
||||
/// </summary>
|
||||
public MqttSparkplugOptions? Sparkplug { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plain-mode-only settings. Populated when <see cref="Mode"/> is
|
||||
/// <see cref="MqttMode.Plain"/>; <c>null</c> in Sparkplug B mode.
|
||||
/// </summary>
|
||||
public MqttPlainOptions? Plain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Record-generated <c>ToString()</c> member printer, overridden so <see cref="Password"/>
|
||||
/// never renders in plaintext — this DTO routinely lands in logs / exception messages /
|
||||
/// debugger watches, and the plan's cross-cutting rule is explicit: never commit or log
|
||||
/// creds. A set password prints as <c>***</c>; unset (<c>null</c>/empty) renders
|
||||
/// distinguishably so the redaction can't be mistaken for a real secret.
|
||||
/// </summary>
|
||||
private bool PrintMembers(StringBuilder builder)
|
||||
{
|
||||
var passwordDisplay = Password switch
|
||||
{
|
||||
null => "<null>",
|
||||
"" => "<empty>",
|
||||
_ => "***",
|
||||
};
|
||||
|
||||
builder.Append("Host = ").Append(Host);
|
||||
builder.Append(", Port = ").Append(Port);
|
||||
builder.Append(", ClientId = ").Append(ClientId);
|
||||
builder.Append(", UseTls = ").Append(UseTls);
|
||||
builder.Append(", AllowUntrustedServerCertificate = ").Append(AllowUntrustedServerCertificate);
|
||||
builder.Append(", CaCertificatePath = ").Append(CaCertificatePath);
|
||||
builder.Append(", Username = ").Append(Username);
|
||||
builder.Append(", Password = ").Append(passwordDisplay);
|
||||
builder.Append(", ProtocolVersion = ").Append(ProtocolVersion);
|
||||
builder.Append(", CleanSession = ").Append(CleanSession);
|
||||
builder.Append(", KeepAliveSeconds = ").Append(KeepAliveSeconds);
|
||||
builder.Append(", ConnectTimeoutSeconds = ").Append(ConnectTimeoutSeconds);
|
||||
builder.Append(", ReconnectMinBackoffSeconds = ").Append(ReconnectMinBackoffSeconds);
|
||||
builder.Append(", ReconnectMaxBackoffSeconds = ").Append(ReconnectMaxBackoffSeconds);
|
||||
builder.Append(", MaxPayloadBytes = ").Append(MaxPayloadBytes);
|
||||
builder.Append(", Mode = ").Append(Mode);
|
||||
builder.Append(", Sparkplug = ").Append(Sparkplug);
|
||||
builder.Append(", Plain = ").Append(Plain);
|
||||
// Count only: a deployment routinely authors thousands of raw tags and each carries a full
|
||||
// TagConfig blob, so printing the list would turn any log of this DTO into a config dump.
|
||||
builder.Append(", RawTags = ").Append(RawTags.Count).Append(" tag(s)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug B settings for an <see cref="MqttDriverOptions"/> instance in
|
||||
/// <see cref="MqttMode.SparkplugB"/> mode. See
|
||||
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
|
||||
/// </summary>
|
||||
public sealed record MqttSparkplugOptions
|
||||
{
|
||||
/// <summary>Sparkplug group id — the driver subscribes <c>spBv1.0/{GroupId}/#</c>.</summary>
|
||||
public string GroupId { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Sparkplug Host Application identity — published as <c>spBv1.0/STATE/{HostId}</c>
|
||||
/// (Sparkplug v3.0).
|
||||
/// </summary>
|
||||
public string HostId { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, this driver instance acts as the Sparkplug Primary Host
|
||||
/// Application. At most one primary host may exist per host-id per broker.
|
||||
/// </summary>
|
||||
public bool ActAsPrimaryHost { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, a detected sequence-number gap triggers a Sparkplug rebirth
|
||||
/// request (NCMD) rather than silently continuing with stale metric state.
|
||||
/// </summary>
|
||||
public bool RequestRebirthOnGap { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before
|
||||
/// considering the observed metric set stable.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int BirthObservationWindowSeconds { get; init; } = 15;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plain-MQTT-mode settings for an <see cref="MqttDriverOptions"/> instance in
|
||||
/// <see cref="MqttMode.Plain"/> mode. See
|
||||
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
|
||||
/// </summary>
|
||||
public sealed record MqttPlainOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional prefix prepended when the driver needs to compose a topic (e.g. discovery
|
||||
/// scoping); per-tag topics are authored explicitly and are unaffected.
|
||||
/// </summary>
|
||||
public string TopicPrefix { get; init; } = "";
|
||||
|
||||
/// <summary>Default QoS used when a tag's own <c>qos</c> is unset.</summary>
|
||||
[Range(0, 2)]
|
||||
public int DefaultQos { get; init; } = 1;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// The <b>single</b> <see cref="JsonSerializerOptions"/> instance every MQTT driver-config seam
|
||||
/// parses <see cref="MqttDriverOptions"/> through — the runtime factory
|
||||
/// (<c>MqttDriverFactoryExtensions</c>), the Test-connect probe (<c>MqttDriverProbe</c>), the
|
||||
/// driver's own <c>ReinitializeAsync</c> re-parse, and the address-picker browser
|
||||
/// (<c>MqttDriverBrowser</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why one instance, and why here.</b> Divergent per-seam options are this repo's
|
||||
/// documented systemic enum bug: an AdminUI-authored config with an enum-valued field is
|
||||
/// accepted by the seam that carries a <see cref="JsonStringEnumConverter"/> and faults the
|
||||
/// one that does not, so "Test connect" goes green and the deployed driver dies. Two other
|
||||
/// drivers already carry a copy per seam. Rather than repeat that, this driver keeps exactly
|
||||
/// one instance.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It lives in <c>.Contracts</c> — the assembly that owns <see cref="MqttDriverOptions"/> and
|
||||
/// the three enums the converter exists for — rather than in the factory or the probe,
|
||||
/// because <c>.Contracts</c> is the <i>only</i> assembly all four consumers already reference.
|
||||
/// In particular the browser lives in its own assembly and reaches the runtime <c>.Driver</c>
|
||||
/// project only through a deliberate, documented layering exception that is scheduled to be
|
||||
/// removed (see the <c>ProjectReference</c> comment in the browser's csproj); anchoring the
|
||||
/// options in <c>.Driver</c> would resurrect the duplicate the day that reference goes away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not a general-purpose JSON policy.</b> <c>UnmappedMemberHandling.Skip</c> means an
|
||||
/// unknown key is ignored rather than rejected — deliberate, so a config blob authored
|
||||
/// against a newer driver still binds — and <c>PropertyNameCaseInsensitive</c> accepts both
|
||||
/// the camelCase the AdminUI emits and the PascalCase a hand-edited blob may carry. A
|
||||
/// <see cref="JsonSerializerOptions"/> becomes read-only on first use, so this instance is
|
||||
/// safe to share across threads and must never be mutated after startup.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MqttJson
|
||||
{
|
||||
/// <summary>
|
||||
/// The shared options. Enum-valued knobs (<see cref="MqttMode"/>,
|
||||
/// <see cref="MqttProtocolVersion"/>, <see cref="MqttPayloadFormat"/>) round-trip by
|
||||
/// <b>name</b>; numeric ordinals still bind, so an older blob is not broken by this.
|
||||
/// </summary>
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the ingest shape an <see cref="MqttDriverOptions"/> instance uses. See
|
||||
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
|
||||
/// </summary>
|
||||
public enum MqttMode
|
||||
{
|
||||
/// <summary>Plain MQTT — the driver subscribes to authored topics directly.</summary>
|
||||
Plain,
|
||||
|
||||
/// <summary>Sparkplug B — the driver decodes Tahu-encoded NBIRTH/DBIRTH/NDATA/DDATA payloads.</summary>
|
||||
SparkplugB,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// How a Plain-mode MQTT tag's payload is decoded. See
|
||||
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.3.
|
||||
/// </summary>
|
||||
public enum MqttPayloadFormat
|
||||
{
|
||||
/// <summary>The payload is a JSON document; a JSONPath selects the value (<c>jsonPath</c>).</summary>
|
||||
Json,
|
||||
|
||||
/// <summary>The payload bytes are used as-is (e.g. binary / opaque).</summary>
|
||||
Raw,
|
||||
|
||||
/// <summary>The payload is a bare scalar string (e.g. <c>"23.5"</c>), parsed by <c>dataType</c>.</summary>
|
||||
Scalar,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// MQTT wire protocol version negotiated at CONNECT. See
|
||||
/// <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.1.
|
||||
/// </summary>
|
||||
public enum MqttProtocolVersion
|
||||
{
|
||||
/// <summary>MQTT 3.1.1.</summary>
|
||||
V311,
|
||||
|
||||
/// <summary>MQTT 5.0.</summary>
|
||||
V500,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>TagConfig</c> JSON key names that make up an MQTT tag's <b>address</b>, in the two shapes
|
||||
/// the driver binds by: a single <see cref="Topic"/> (Plain) or the
|
||||
/// <see cref="GroupId"/>/<see cref="EdgeNodeId"/>/<see cref="DeviceId"/>/<see cref="MetricName"/>
|
||||
/// tuple (Sparkplug B).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These exist because the address is produced in one place and consumed in another, and the
|
||||
/// two used to agree only by coincidence: the AdminUI browse-commit mapper writes the blob,
|
||||
/// <see cref="MqttTagDefinitionFactory"/> reads it, and a key-name drift between them is not a
|
||||
/// compile error — it is a tag that deploys clean, resolves to nothing, and reports
|
||||
/// <c>BadNodeIdUnknown</c> at runtime with no authoring-time signal at all. Single-sourcing
|
||||
/// the names makes that drift impossible rather than merely unlikely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Address keys only.</b> The behavioural keys (<c>payloadFormat</c>, <c>jsonPath</c>,
|
||||
/// <c>dataType</c>, <c>qos</c>, <c>retainSeed</c>) are deliberately not here — nothing produces
|
||||
/// them from a browse, so they carry no cross-component drift risk.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MqttTagConfigKeys
|
||||
{
|
||||
/// <summary>The concrete MQTT topic a Plain-mode tag subscribes to.</summary>
|
||||
public const string Topic = "topic";
|
||||
|
||||
/// <summary>The Sparkplug group id — the first segment of the tag's binding tuple.</summary>
|
||||
public const string GroupId = "groupId";
|
||||
|
||||
/// <summary>The Sparkplug edge-node id.</summary>
|
||||
public const string EdgeNodeId = "edgeNodeId";
|
||||
|
||||
/// <summary>The Sparkplug device id; absent for a metric published by the edge node itself.</summary>
|
||||
public const string DeviceId = "deviceId";
|
||||
|
||||
/// <summary>The Sparkplug metric's stable name — the tag's binding key across rebirths.</summary>
|
||||
public const string MetricName = "metricName";
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// The driver's internal per-tag descriptor — the parsed form of an authored raw tag's
|
||||
/// <c>TagConfig</c> JSON, produced by <see cref="MqttTagDefinitionFactory.FromTagConfig"/> and
|
||||
/// looked up through the shared <see cref="EquipmentTagRefResolver{TDef}"/> exactly as Modbus does.
|
||||
/// See <c>docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md</c> §5.2 / §5.3.
|
||||
/// <para>
|
||||
/// The record carries <b>both</b> ingest shapes: the Plain-MQTT fields
|
||||
/// (<see cref="Topic"/> … <see cref="RetainSeed"/>), populated by
|
||||
/// <see cref="MqttTagDefinitionFactory.FromTagConfig"/>, and the Sparkplug B descriptor fields
|
||||
/// (<see cref="GroupId"/>, <see cref="EdgeNodeId"/>, <see cref="DeviceId"/>,
|
||||
/// <see cref="MetricName"/>), populated by
|
||||
/// <see cref="MqttTagDefinitionFactory.FromSparkplugTagConfig"/> (Task 21). A Sparkplug tag is
|
||||
/// resolved by the stable <c>(group, node, device, metricName)</c> tuple, <b>never</b> by the
|
||||
/// per-birth metric alias — an alias may be reused across a rebirth for a different metric.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Which factory runs is chosen by the driver's <see cref="MqttMode"/>, never sniffed from
|
||||
/// the blob.</b> A blob carrying both shapes' keys is legal (the AdminUI editor preserves
|
||||
/// unknown keys through a load→save, so a tag retyped from Plain to Sparkplug keeps its old
|
||||
/// <c>topic</c>), and a heuristic would make the same authored tag mean two different things
|
||||
/// depending on which key happened to survive.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Name">
|
||||
/// The definition's identity: the tag's <b>RawPath</b> — the cluster-scoped slash path that is the
|
||||
/// v3 driver wire reference. This is the key <see cref="EquipmentTagRefResolver{TDef}"/> resolves
|
||||
/// on, the key <c>OnDataChange</c> must publish under, and the key <c>DriverHostActor</c> fans out
|
||||
/// to the raw + UNS NodeIds. It is emphatically <b>not</b> the authored <c>TagConfig</c> blob: the
|
||||
/// pre-v3 blob-as-reference shape is retired, and publishing under a blob key would silently miss
|
||||
/// the RawPath-keyed fan-out while every unit test still passed.
|
||||
/// </param>
|
||||
/// <param name="Topic">The concrete MQTT topic this tag subscribes to (Plain mode).</param>
|
||||
/// <param name="PayloadFormat">How the received payload is decoded (Plain mode).</param>
|
||||
/// <param name="JsonPath">
|
||||
/// JSONPath selecting the value inside a <see cref="MqttPayloadFormat.Json"/> payload. Defaults
|
||||
/// to the document root (<c>$</c>) when the blob omits it; meaningless for
|
||||
/// <see cref="MqttPayloadFormat.Raw"/> / <see cref="MqttPayloadFormat.Scalar"/>.
|
||||
/// </param>
|
||||
/// <param name="DataType">
|
||||
/// The tag's declared value type. Explicit authoring is strongly preferred — payload-shape
|
||||
/// inference is the brittle fallback (design §4).
|
||||
/// </param>
|
||||
/// <param name="Qos">
|
||||
/// The per-tag subscription QoS (0–2), or <see langword="null"/> to inherit the driver-level
|
||||
/// <see cref="MqttPlainOptions.DefaultQos"/>.
|
||||
/// </param>
|
||||
/// <param name="RetainSeed">
|
||||
/// Whether the broker's retained message for <see cref="Topic"/> seeds this tag's initial value
|
||||
/// on subscribe (the OPC UA initial-data convention). Defaults to <see langword="true"/>.
|
||||
/// </param>
|
||||
/// <param name="DataTypeAuthored">
|
||||
/// Whether <paramref name="DataType"/> came from an authored <c>dataType</c> key or is merely this
|
||||
/// record's default. Load-bearing in <b>Sparkplug</b> mode only, where <c>dataType</c> is optional:
|
||||
/// a Sparkplug metric's type is declared by its own birth certificate, so an unauthored tag must
|
||||
/// take the type the NBIRTH/DBIRTH declared rather than silently coercing every value to the
|
||||
/// <see cref="DriverDataType.String"/> default. Both factories set it from the key's presence so it
|
||||
/// means the same thing in either mode; nothing on the Plain ingest path reads it.
|
||||
/// </param>
|
||||
/// <param name="GroupId">The Sparkplug group id; <see langword="null"/> for a Plain-mode tag.</param>
|
||||
/// <param name="EdgeNodeId">The Sparkplug edge-node id; <see langword="null"/> for a Plain-mode tag.</param>
|
||||
/// <param name="DeviceId">
|
||||
/// The Sparkplug device id, or <see langword="null"/> for a metric published by the edge node
|
||||
/// itself (and for every Plain-mode tag).
|
||||
/// </param>
|
||||
/// <param name="MetricName">
|
||||
/// The Sparkplug metric name — the tag's stable identity across rebirths, and the key the ingest
|
||||
/// state machine binds by. <see langword="null"/> for a Plain-mode tag.
|
||||
/// </param>
|
||||
public sealed record MqttTagDefinition(
|
||||
string Name,
|
||||
string Topic,
|
||||
MqttPayloadFormat PayloadFormat,
|
||||
string JsonPath,
|
||||
DriverDataType DataType,
|
||||
int? Qos,
|
||||
bool RetainSeed,
|
||||
bool DataTypeAuthored = true,
|
||||
string? GroupId = null,
|
||||
string? EdgeNodeId = null,
|
||||
string? DeviceId = null,
|
||||
string? MetricName = null);
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
|
||||
/// AdminUI <c>MqttTagConfigModel</c>) into an <see cref="MqttTagDefinition"/>. Under v3 a tag's
|
||||
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
|
||||
/// produced definition's <see cref="MqttTagDefinition.Name"/> is the RawPath the driver was handed,
|
||||
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
|
||||
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
|
||||
/// <see cref="FromTagConfig"/>.
|
||||
/// <para>
|
||||
/// Two entry points carry deliberately different strictness contracts.
|
||||
/// <see cref="FromTagConfig"/> is the <b>runtime</b> path: it never throws, and anything
|
||||
/// malformed returns <see langword="false"/> so the driver surfaces <c>BadNodeIdUnknown</c>
|
||||
/// rather than a misleading <c>Good</c> off a wrong-typed default. <see cref="Inspect"/> is the
|
||||
/// <b>deploy-time</b> path: it returns human-readable warnings so a bad tag config surfaces at
|
||||
/// deploy instead of silently going dark at runtime.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two runtime entry points, one per ingest shape</b> — <see cref="FromTagConfig"/> (Plain)
|
||||
/// and <see cref="FromSparkplugTagConfig"/> (Sparkplug B). The caller picks by the driver's
|
||||
/// <see cref="MqttMode"/>; neither sniffs the blob. That is deliberate: the AdminUI editor
|
||||
/// preserves unknown keys through a load→save, so a tag retyped from Plain to Sparkplug keeps
|
||||
/// its old <c>topic</c> and a presence heuristic would make one authored blob mean two
|
||||
/// different things depending on which key happened to survive. The <i>driver's</i> mode is
|
||||
/// the single authority.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No <c>ToTagConfig</c> inverse — a deliberate YAGNI call.</b> The six sibling factories
|
||||
/// each carry one solely to serve their <c>Driver.<X>.Cli</c> project, which synthesises
|
||||
/// <see cref="RawTagEntry"/> blobs from operator flags; the MQTT plan defines no
|
||||
/// <c>Mqtt.Cli</c>. The AdminUI typed editor does not need one either — the
|
||||
/// <c><Driver>TagConfigModel</c> template serialises through its own preserved
|
||||
/// <c>JsonObject</c> key bag and references no driver factory (verified: no
|
||||
/// <c>TagDefinitionFactory</c> reference exists anywhere under the AdminUI project). Add the
|
||||
/// inverse when a real caller appears, not before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MqttTagDefinitionFactory
|
||||
{
|
||||
/// <summary>The JSONPath applied when a Json-format blob omits <c>jsonPath</c>: the document root.</summary>
|
||||
private const string RootJsonPath = "$";
|
||||
|
||||
/// <summary>The MQTT wildcard characters — illegal in a <em>tag's</em> (concrete) subscription topic.</summary>
|
||||
private static readonly char[] TopicWildcards = ['+', '#'];
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
|
||||
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
|
||||
/// Enum fields (<c>payloadFormat</c> / <c>dataType</c>) and <c>qos</c> are read STRICTLY — a
|
||||
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
|
||||
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-typed Good or
|
||||
/// a downgraded delivery guarantee. Never throws.
|
||||
/// <para>
|
||||
/// A <b>wildcard</b> topic (<c>+</c> / <c>#</c>) is deliberately NOT rejected here: it is
|
||||
/// ambiguous rather than unparseable, and <see cref="Inspect"/> is the operator-visible surface
|
||||
/// for it. Rejecting it at runtime would turn an authoring mistake into a silent
|
||||
/// <c>BadNodeIdUnknown</c> with no stated cause.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid MQTT tag-config object.</returns>
|
||||
public static bool FromTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
// topic is the whole point of a Plain-mode tag — without one there is nothing to subscribe
|
||||
// to, so an absent/blank value is a hard reject rather than a defaulted empty subscription.
|
||||
var topic = ReadString(root, MqttTagConfigKeys.Topic);
|
||||
if (string.IsNullOrWhiteSpace(topic)) return false;
|
||||
|
||||
// Strict enum reads: a present-but-invalid (typo'd) value rejects the whole tag
|
||||
// (→ BadNodeIdUnknown) instead of silently defaulting to a wrong-typed Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "payloadFormat", MqttPayloadFormat.Json, out var payloadFormat))
|
||||
return false;
|
||||
var dataTypeAuthored = root.TryGetProperty("dataType", out _);
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
|
||||
return false;
|
||||
|
||||
// qos is read with the same strictness, for the same reason: silently absorbing a malformed
|
||||
// "qos":"high" / "qos":1.5 / "qos":5 into the driver-level default would hand the operator a
|
||||
// WEAKER delivery guarantee than the one they authored, with nothing to show for it.
|
||||
if (!TryReadQosStrict(root, out var qos)) return false;
|
||||
|
||||
var jsonPath = ReadString(root, "jsonPath");
|
||||
if (string.IsNullOrEmpty(jsonPath)) jsonPath = RootJsonPath;
|
||||
|
||||
def = new MqttTagDefinition(
|
||||
Name: rawPath,
|
||||
Topic: topic,
|
||||
PayloadFormat: payloadFormat,
|
||||
JsonPath: jsonPath,
|
||||
DataType: dataType,
|
||||
Qos: qos,
|
||||
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
|
||||
DataTypeAuthored: dataTypeAuthored);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
catch (FormatException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authored <c>TagConfig</c> object to a <b>Sparkplug B</b> definition keyed by
|
||||
/// <paramref name="rawPath"/>. The tag's binding identity is the stable
|
||||
/// <c>(groupId, edgeNodeId, deviceId?, metricName)</c> tuple; <c>deviceId</c> is optional (absent
|
||||
/// means the metric is published by the edge node itself), and there is <b>no</b> <c>topic</c> —
|
||||
/// a Sparkplug driver subscribes one group-wide filter and routes by the decoded topic + birth
|
||||
/// certificate, never by a per-tag topic. Never throws.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><c>dataType</c> is OPTIONAL here, unlike in Plain mode.</b> A Sparkplug metric declares
|
||||
/// its own datatype in its birth certificate, so an unauthored tag legitimately takes the
|
||||
/// type the birth declares — that is the whole point of Task 22's <c>UntilStable</c>
|
||||
/// discovery. It is still read <b>strictly</b> when present (a typo'd value rejects the tag,
|
||||
/// exactly as in Plain mode) and the outcome is recorded on
|
||||
/// <see cref="MqttTagDefinition.DataTypeAuthored"/> so the ingest path can tell "the operator
|
||||
/// declared String" from "nobody declared anything and the record defaulted".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Plain-shape keys are read but not required.</b> <c>topic</c>/<c>payloadFormat</c>/
|
||||
/// <c>jsonPath</c> are meaningless to the Sparkplug ingest path and are deliberately NOT
|
||||
/// validated — a blob retyped in the editor keeps them, and rejecting the tag for a stale
|
||||
/// leftover key would take a correctly-authored Sparkplug tag dark with no stated cause.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
|
||||
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
|
||||
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when the blob carries a usable Sparkplug descriptor.</returns>
|
||||
public static bool FromSparkplugTagConfig(string tagConfig, string rawPath, out MqttTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
if (string.IsNullOrWhiteSpace(tagConfig) || tagConfig[0] != '{') return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||||
|
||||
// The binding tuple. Without all three of these there is nothing a birth certificate could
|
||||
// ever bind the tag to, so an absent/blank value is a hard reject (→ BadNodeIdUnknown)
|
||||
// rather than a definition that can never resolve and reports nothing about why.
|
||||
var groupId = ReadString(root, MqttTagConfigKeys.GroupId);
|
||||
var edgeNodeId = ReadString(root, MqttTagConfigKeys.EdgeNodeId);
|
||||
var metricName = ReadString(root, MqttTagConfigKeys.MetricName);
|
||||
if (string.IsNullOrWhiteSpace(groupId)
|
||||
|| string.IsNullOrWhiteSpace(edgeNodeId)
|
||||
|| string.IsNullOrWhiteSpace(metricName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optional: a node-level metric has no device segment. Blank normalises to absent so
|
||||
// "deviceId":"" and an omitted key describe the same scope rather than two.
|
||||
var deviceId = ReadString(root, MqttTagConfigKeys.DeviceId);
|
||||
if (string.IsNullOrWhiteSpace(deviceId)) deviceId = null;
|
||||
|
||||
// Strict, but only when present — see the remarks.
|
||||
var dataTypeAuthored = root.TryGetProperty("dataType", out _);
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", DriverDataType.String, out var dataType))
|
||||
return false;
|
||||
|
||||
if (!TryReadQosStrict(root, out var qos)) return false;
|
||||
|
||||
def = new MqttTagDefinition(
|
||||
Name: rawPath,
|
||||
// Plain-shape fields carried through verbatim; the Sparkplug ingest path reads none of
|
||||
// them, and a retyped blob's leftovers must not change what the tag means.
|
||||
Topic: ReadString(root, MqttTagConfigKeys.Topic),
|
||||
PayloadFormat: MqttPayloadFormat.Json,
|
||||
JsonPath: RootJsonPath,
|
||||
DataType: dataType,
|
||||
Qos: qos,
|
||||
RetainSeed: ReadBoolOrDefault(root, "retainSeed", defaultValue: true),
|
||||
DataTypeAuthored: dataTypeAuthored,
|
||||
GroupId: groupId,
|
||||
EdgeNodeId: edgeNodeId,
|
||||
DeviceId: deviceId,
|
||||
MetricName: metricName);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
catch (FormatException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection of an equipment-tag <c>TagConfig</c> blob. Returns human-readable
|
||||
/// warnings for a structurally unparseable blob (which the runtime turns into a silent
|
||||
/// <c>BadNodeIdUnknown</c>), for present-but-invalid <c>payloadFormat</c> / <c>dataType</c> /
|
||||
/// <c>qos</c> values, and for a <b>wildcard</b> tag topic (a tag bound to <c>+</c> / <c>#</c>
|
||||
/// would receive values from many topics — ambiguous, and almost never what the operator meant).
|
||||
/// Empty when the blob is clean or is not an equipment-tag TagConfig object. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(reference);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
warnings.Add("Mqtt TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
|
||||
return warnings;
|
||||
}
|
||||
foreach (var w in new[]
|
||||
{
|
||||
TagConfigJson.DescribeInvalidEnum<MqttPayloadFormat>(root, "payloadFormat"),
|
||||
TagConfigJson.DescribeInvalidEnum<DriverDataType>(root, "dataType"),
|
||||
DescribeInvalidQos(root),
|
||||
})
|
||||
{
|
||||
if (w is not null) warnings.Add(w);
|
||||
}
|
||||
var topic = ReadString(root, "topic");
|
||||
if (topic.IndexOfAny(TopicWildcards) >= 0)
|
||||
{
|
||||
warnings.Add(
|
||||
$"value '{topic}' for 'topic' contains an MQTT wildcard (+ or #); a tag's topic must be " +
|
||||
"concrete, or the tag will be fed by every matching topic.");
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
warnings.Add("Mqtt TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strict <c>qos</c> read, mirroring <see cref="TagConfigJson.TryReadEnumStrict{TEnum}"/>'s
|
||||
/// absent / valid / present-but-invalid split: absent ⇒ <see langword="null"/> (the driver-level
|
||||
/// <see cref="MqttPlainOptions.DefaultQos"/> wins); a JSON integer in 0–2 ⇒ that value; anything
|
||||
/// else present (non-number, non-integer, or out of range) ⇒ the read FAILS.
|
||||
/// </summary>
|
||||
/// <param name="o">The TagConfig root object.</param>
|
||||
/// <param name="qos">The parsed QoS, or <see langword="null"/> when the field is absent.</param>
|
||||
/// <returns><see langword="false"/> only when <c>qos</c> is present but not a legal MQTT QoS.</returns>
|
||||
private static bool TryReadQosStrict(JsonElement o, out int? qos)
|
||||
{
|
||||
qos = null;
|
||||
if (!o.TryGetProperty("qos", out var e)) return true; // absent
|
||||
if (e.ValueKind != JsonValueKind.Number || !e.TryGetInt32(out var v)) return false;
|
||||
if (v is < 0 or > 2) return false;
|
||||
qos = v;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A human-readable warning for a present-but-invalid <c>qos</c> field, or <see langword="null"/>
|
||||
/// when it is absent or valid — the <c>qos</c> counterpart of
|
||||
/// <see cref="TagConfigJson.DescribeInvalidEnum{TEnum}"/>.
|
||||
/// </summary>
|
||||
/// <param name="o">The TagConfig root object.</param>
|
||||
/// <returns>The warning text, or <see langword="null"/>.</returns>
|
||||
private static string? DescribeInvalidQos(JsonElement o)
|
||||
{
|
||||
if (!o.TryGetProperty("qos", out var e)) return null;
|
||||
if (e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) && v is >= 0 and <= 2) return null;
|
||||
return $"value '{e.GetRawText()}' for 'qos' is not a valid MQTT QoS; valid: 0, 1, 2";
|
||||
}
|
||||
|
||||
private static string ReadString(JsonElement o, string name)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
|
||||
? e.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
private static bool ReadBoolOrDefault(JsonElement o, string name, bool defaultValue)
|
||||
=> o.TryGetProperty(name, out var e) && e.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? e.GetBoolean()
|
||||
: defaultValue;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// =====================================================================================================
|
||||
// VENDORED THIRD-PARTY FILE — DO NOT EDIT. Re-vendor from upstream instead (recipe below).
|
||||
//
|
||||
// Upstream project Eclipse Tahu — the Sparkplug B reference implementation
|
||||
// Upstream repo https://github.com/eclipse-tahu/tahu
|
||||
// Upstream path sparkplug_b/sparkplug_b.proto
|
||||
// Pinned commit 5736e404889d4b95910613040a99ba79589ffb13 (master, 2023-11-06)
|
||||
// Permalink https://raw.githubusercontent.com/eclipse-tahu/tahu/5736e404889d4b95910613040a99ba79589ffb13/sparkplug_b/sparkplug_b.proto
|
||||
// git blob SHA-1 bf72ab5f09a333afabcb40fd45362ffbb0c8c5bd (matches the tree entry at that commit)
|
||||
// content SHA-256 4432c5c483b7fb9732d0594c98a2e97dca5e517e39c5374a8b918d837f0b4a19 (8330 bytes)
|
||||
// last modified 46f25e79f34234e6145d11108660dfd9133ae50d (2022-05-16, template_ref comment fix)
|
||||
// License Eclipse Public License 2.0 (EPL-2.0) — https://www.eclipse.org/legal/epl-2.0/
|
||||
// Copyright (c) Cirrus Link Solutions and others. The upstream copyright + SPDX
|
||||
// header is preserved verbatim as the first lines of the copied body below.
|
||||
//
|
||||
// Everything from line 38 down ("// * Copyright (c) 2015, 2018 Cirrus Link Solutions…") is a
|
||||
// BYTE-FOR-BYTE copy of the upstream file. Only these header lines were added. To re-verify:
|
||||
//
|
||||
// curl -sSL <permalink above> -o /tmp/upstream.proto
|
||||
// tail -n +38 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto > /tmp/vendored.proto
|
||||
// diff /tmp/upstream.proto /tmp/vendored.proto && shasum -a 256 /tmp/vendored.proto
|
||||
//
|
||||
// WHY VENDORED rather than a NuGet package: SparkplugNet is the only .NET Sparkplug library and it is
|
||||
// stale (1.3.10, 2024-07-02), transitively pins MQTTnet 4.3.6.1152 against this repo's MQTTnet 5.2.0,
|
||||
// and ships net6.0/net8.0 only — no net10.0 TFM. So the schema is vendored and decoded directly with
|
||||
// Google.Protobuf, over the same single MQTTnet-5 client the plain-MQTT path already uses.
|
||||
//
|
||||
// WHY THE proto2 FILE and not the sibling sparkplug_b_c_sharp.proto that upstream also ships: this is
|
||||
// the NORMATIVE Sparkplug B schema (the C# sibling is a lossy proto3 restatement that drops explicit
|
||||
// presence and rewrites the `extensions` ranges as `google.protobuf.Any`). protoc from Grpc.Tools
|
||||
// generates valid C# from proto2, and the generated namespace is identical (Org.Eclipse.Tahu.Protobuf,
|
||||
// PascalCased from `package org.eclipse.tahu.protobuf` — there is no `option csharp_namespace`). Keeping
|
||||
// proto2 buys explicit presence — Has{Name,Alias,Seq,IsNull,Datatype} — which the decoder needs to tell
|
||||
// "field absent" from "field present and zero" (an NBIRTH legitimately carries seq = 0, and a DATA
|
||||
// metric legitimately omits `name` and carries only `alias`).
|
||||
// =====================================================================================================
|
||||
|
||||
// * Copyright (c) 2015, 2018 Cirrus Link Solutions and others
|
||||
// *
|
||||
// * This program and the accompanying materials are made available under the
|
||||
// * terms of the Eclipse Public License 2.0 which is available at
|
||||
// * http://www.eclipse.org/legal/epl-2.0.
|
||||
// *
|
||||
// * SPDX-License-Identifier: EPL-2.0
|
||||
// *
|
||||
// * Contributors:
|
||||
// * Cirrus Link Solutions - initial implementation
|
||||
|
||||
//
|
||||
// To compile:
|
||||
// cd client_libraries/java
|
||||
// protoc --proto_path=../../ --java_out=src/main/java ../../sparkplug_b.proto
|
||||
//
|
||||
|
||||
syntax = "proto2";
|
||||
|
||||
package org.eclipse.tahu.protobuf;
|
||||
|
||||
option java_package = "org.eclipse.tahu.protobuf";
|
||||
option java_outer_classname = "SparkplugBProto";
|
||||
|
||||
enum DataType {
|
||||
// Indexes of Data Types
|
||||
|
||||
// Unknown placeholder for future expansion.
|
||||
Unknown = 0;
|
||||
|
||||
// Basic Types
|
||||
Int8 = 1;
|
||||
Int16 = 2;
|
||||
Int32 = 3;
|
||||
Int64 = 4;
|
||||
UInt8 = 5;
|
||||
UInt16 = 6;
|
||||
UInt32 = 7;
|
||||
UInt64 = 8;
|
||||
Float = 9;
|
||||
Double = 10;
|
||||
Boolean = 11;
|
||||
String = 12;
|
||||
DateTime = 13;
|
||||
Text = 14;
|
||||
|
||||
// Additional Metric Types
|
||||
UUID = 15;
|
||||
DataSet = 16;
|
||||
Bytes = 17;
|
||||
File = 18;
|
||||
Template = 19;
|
||||
|
||||
// Additional PropertyValue Types
|
||||
PropertySet = 20;
|
||||
PropertySetList = 21;
|
||||
|
||||
// Array Types
|
||||
Int8Array = 22;
|
||||
Int16Array = 23;
|
||||
Int32Array = 24;
|
||||
Int64Array = 25;
|
||||
UInt8Array = 26;
|
||||
UInt16Array = 27;
|
||||
UInt32Array = 28;
|
||||
UInt64Array = 29;
|
||||
FloatArray = 30;
|
||||
DoubleArray = 31;
|
||||
BooleanArray = 32;
|
||||
StringArray = 33;
|
||||
DateTimeArray = 34;
|
||||
}
|
||||
|
||||
message Payload {
|
||||
|
||||
message Template {
|
||||
|
||||
message Parameter {
|
||||
optional string name = 1;
|
||||
optional uint32 type = 2;
|
||||
|
||||
oneof value {
|
||||
uint32 int_value = 3;
|
||||
uint64 long_value = 4;
|
||||
float float_value = 5;
|
||||
double double_value = 6;
|
||||
bool boolean_value = 7;
|
||||
string string_value = 8;
|
||||
ParameterValueExtension extension_value = 9;
|
||||
}
|
||||
|
||||
message ParameterValueExtension {
|
||||
extensions 1 to max;
|
||||
}
|
||||
}
|
||||
|
||||
optional string version = 1; // The version of the Template to prevent mismatches
|
||||
repeated Metric metrics = 2; // Each metric includes a name, datatype, and optionally a value
|
||||
repeated Parameter parameters = 3;
|
||||
optional string template_ref = 4; // MUST be a reference to a template definition if this is an instance (i.e. the name of the template definition) - MUST be omitted for template definitions
|
||||
optional bool is_definition = 5;
|
||||
extensions 6 to max;
|
||||
}
|
||||
|
||||
message DataSet {
|
||||
|
||||
message DataSetValue {
|
||||
|
||||
oneof value {
|
||||
uint32 int_value = 1;
|
||||
uint64 long_value = 2;
|
||||
float float_value = 3;
|
||||
double double_value = 4;
|
||||
bool boolean_value = 5;
|
||||
string string_value = 6;
|
||||
DataSetValueExtension extension_value = 7;
|
||||
}
|
||||
|
||||
message DataSetValueExtension {
|
||||
extensions 1 to max;
|
||||
}
|
||||
}
|
||||
|
||||
message Row {
|
||||
repeated DataSetValue elements = 1;
|
||||
extensions 2 to max; // For third party extensions
|
||||
}
|
||||
|
||||
optional uint64 num_of_columns = 1;
|
||||
repeated string columns = 2;
|
||||
repeated uint32 types = 3;
|
||||
repeated Row rows = 4;
|
||||
extensions 5 to max; // For third party extensions
|
||||
}
|
||||
|
||||
message PropertyValue {
|
||||
|
||||
optional uint32 type = 1;
|
||||
optional bool is_null = 2;
|
||||
|
||||
oneof value {
|
||||
uint32 int_value = 3;
|
||||
uint64 long_value = 4;
|
||||
float float_value = 5;
|
||||
double double_value = 6;
|
||||
bool boolean_value = 7;
|
||||
string string_value = 8;
|
||||
PropertySet propertyset_value = 9;
|
||||
PropertySetList propertysets_value = 10; // List of Property Values
|
||||
PropertyValueExtension extension_value = 11;
|
||||
}
|
||||
|
||||
message PropertyValueExtension {
|
||||
extensions 1 to max;
|
||||
}
|
||||
}
|
||||
|
||||
message PropertySet {
|
||||
repeated string keys = 1; // Names of the properties
|
||||
repeated PropertyValue values = 2;
|
||||
extensions 3 to max;
|
||||
}
|
||||
|
||||
message PropertySetList {
|
||||
repeated PropertySet propertyset = 1;
|
||||
extensions 2 to max;
|
||||
}
|
||||
|
||||
message MetaData {
|
||||
// Bytes specific metadata
|
||||
optional bool is_multi_part = 1;
|
||||
|
||||
// General metadata
|
||||
optional string content_type = 2; // Content/Media type
|
||||
optional uint64 size = 3; // File size, String size, Multi-part size, etc
|
||||
optional uint64 seq = 4; // Sequence number for multi-part messages
|
||||
|
||||
// File metadata
|
||||
optional string file_name = 5; // File name
|
||||
optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc)
|
||||
optional string md5 = 7; // md5 of data
|
||||
|
||||
// Catchalls and future expansion
|
||||
optional string description = 8; // Could be anything such as json or xml of custom properties
|
||||
extensions 9 to max;
|
||||
}
|
||||
|
||||
message Metric {
|
||||
|
||||
optional string name = 1; // Metric name - should only be included on birth
|
||||
optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages
|
||||
optional uint64 timestamp = 3; // Timestamp associated with data acquisition time
|
||||
optional uint32 datatype = 4; // DataType of the metric/tag value
|
||||
optional bool is_historical = 5; // If this is historical data and should not update real time tag
|
||||
optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag
|
||||
optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes.
|
||||
optional MetaData metadata = 8; // Metadata for the payload
|
||||
optional PropertySet properties = 9;
|
||||
|
||||
oneof value {
|
||||
uint32 int_value = 10;
|
||||
uint64 long_value = 11;
|
||||
float float_value = 12;
|
||||
double double_value = 13;
|
||||
bool boolean_value = 14;
|
||||
string string_value = 15;
|
||||
bytes bytes_value = 16; // Bytes, File
|
||||
DataSet dataset_value = 17;
|
||||
Template template_value = 18;
|
||||
MetricValueExtension extension_value = 19;
|
||||
}
|
||||
|
||||
message MetricValueExtension {
|
||||
extensions 1 to max;
|
||||
}
|
||||
}
|
||||
|
||||
optional uint64 timestamp = 1; // Timestamp at message sending time
|
||||
repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs
|
||||
optional uint64 seq = 3; // Sequence number
|
||||
optional string uuid = 4; // UUID to track message type in terms of schema definitions
|
||||
optional bytes body = 5; // To optionally bypass the whole definition above
|
||||
extensions 6 to max; // For third party extensions
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// `SparkplugDataType` is an ALIAS for the vendored proto's generated `Org.Eclipse.Tahu.Protobuf.DataType`
|
||||
// enum, not a second, hand-maintained enum. See the remarks on `SparkplugDataTypeExtensions` below for
|
||||
// the reasoning; the short version is that a duplicate enum is a drift hazard this repo has a documented
|
||||
// systemic bug class around (see CLAUDE.md "Driver enum-serialization bug"), and `Payload.Types.Metric`'s
|
||||
// wire-level `Datatype` field is a raw `uint32` anyway — nothing structurally forces a second CLR enum to
|
||||
// exist, so the lowest-risk shape is for `SparkplugDataType` to be the exact same type as the generated
|
||||
// one, not a value-compatible lookalike that some cast has to bridge.
|
||||
global using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a Sparkplug B metric <see cref="SparkplugDataType"/> (the vendored Eclipse Tahu proto's
|
||||
/// generated <c>Org.Eclipse.Tahu.Protobuf.DataType</c> enum — see the <c>global using</c> alias
|
||||
/// above) to a driver-agnostic <see cref="DriverDataType"/>, per design doc §3.5.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why an alias, not a duplicate enum.</b> The obvious "clean seam" shape is a fresh
|
||||
/// <c>Driver.Mqtt.Contracts</c>-owned enum with its own <c>ToDriverDataType()</c> — but that
|
||||
/// creates a second definition of the same 35-member vocabulary that has to be hand-kept in
|
||||
/// sync with whatever Eclipse Tahu's <c>sparkplug_b.proto</c> defines, which is exactly the
|
||||
/// enum-drift shape this repo already has a name for (CLAUDE.md's "Driver enum-serialization
|
||||
/// bug (AdminUI authoring)" — a systemic mismatch between two enums meant to describe the same
|
||||
/// thing). It also does not match how the decoder actually produces values: Sparkplug's
|
||||
/// <c>Metric.datatype</c> wire field is a raw <c>uint32</c> (see <c>sparkplug_b.proto</c> line
|
||||
/// 230 — <c>optional uint32 datatype = 4</c>), not the <c>DataType</c> enum type itself, so
|
||||
/// <c>SparkplugCodec</c> (Task 16) already casts the decoded value straight to the generated
|
||||
/// <c>Org.Eclipse.Tahu.Protobuf.DataType</c> (locally aliased there as <c>TahuDataType</c>).
|
||||
/// A second, hand-duplicated enum would force every downstream consumer (Tasks 18/19/20/21) to
|
||||
/// cast between two value-compatible-but-nominally-different enums to call this extension
|
||||
/// method — extra surface for exactly zero benefit, since both "enums" would need to enumerate
|
||||
/// the identical 35 members in the identical order to stay castable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Making <see cref="SparkplugDataType"/> a <c>global using</c> alias for the generated type
|
||||
/// sidesteps all of that: there is no second definition to drift, by construction — the alias
|
||||
/// and the generated enum are the exact same CLR type. <see cref="ToDriverDataType"/> below is
|
||||
/// written as an extension on <see cref="SparkplugDataType"/> purely for call-site readability;
|
||||
/// it is equally callable as <c>someDecodedDatatype.ToDriverDataType()</c> against a plain
|
||||
/// <c>Org.Eclipse.Tahu.Protobuf.DataType</c> value with no cast, which is exactly the shape
|
||||
/// <c>SparkplugCodec</c>'s decoded metrics hand back.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Drift guard.</b> Because there is only one enum, <c>SparkplugDataTypeTests</c>'
|
||||
/// completeness test (<c>ToDriverDataType_HandlesEveryGeneratedDataTypeMember_...</c>) can
|
||||
/// enumerate <c>Enum.GetValues<SparkplugDataType>()</c> — which, being the alias, is
|
||||
/// literally the live generated member set — and assert every member is either mapped or on
|
||||
/// the explicit unsupported list below. If Eclipse Tahu's proto ever gains a member, that test
|
||||
/// picks it up automatically and fails until this map makes an explicit decision about it,
|
||||
/// rather than the new member silently falling through a duplicate-enum's stale default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per design §3.5: <c>Int8</c>/<c>UInt8</c> widen to <see cref="DriverDataType.Int16"/> /
|
||||
/// <see cref="DriverDataType.UInt16"/> (no OPC UA signed-byte distinction is needed and
|
||||
/// <see cref="DriverDataType"/> has no 8-bit members at all); <c>Float</c>/<c>Double</c> map to
|
||||
/// <see cref="DriverDataType.Float32"/> / <see cref="DriverDataType.Float64"/> — note there is
|
||||
/// no <c>DriverDataType.Double</c> member, only <c>Float64</c>; <c>Text</c>/<c>UUID</c>
|
||||
/// (generated as <c>Uuid</c> — protoc mangles the wire spelling, see
|
||||
/// <c>SparkplugProtoCodegenTests.DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling</c>)
|
||||
/// /<c>Bytes</c>/<c>File</c> all fall back to <see cref="DriverDataType.String"/> (v1 base64/raw
|
||||
/// fallback for Bytes/File, per design); every <c>*Array</c> variant maps to its scalar element
|
||||
/// type (<see cref="IsSparkplugArray"/> carries the "and it's an array" bit separately, since
|
||||
/// <see cref="DriverDataType"/> itself has no array concept — that lives at the OPC UA
|
||||
/// ValueRank/ArrayDimensions layer the address-space builder owns). <c>DataSet</c>/
|
||||
/// <c>Template</c>/<c>PropertySet</c>/<c>PropertySetList</c>/<c>Unknown</c> are unsupported in
|
||||
/// v1 and map to <see langword="null"/> — deliberately, not a guessed
|
||||
/// <see cref="DriverDataType.String"/>, so a caller has to make an explicit skip-or-warn
|
||||
/// decision (unlike Galaxy's <c>DataTypeMap</c>, which silently defaults unknown codes to
|
||||
/// <c>String</c> for legacy wire-compatibility reasons that do not apply here).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SparkplugDataTypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a Sparkplug metric datatype to the equivalent <see cref="DriverDataType"/>, or
|
||||
/// <see langword="null"/> if the type is unsupported in v1 (<c>DataSet</c>, <c>Template</c>,
|
||||
/// <c>PropertySet</c>, <c>PropertySetList</c>, <c>Unknown</c>). Callers must treat
|
||||
/// <see langword="null"/> as an explicit "skip and warn", never coerce it to a guessed type.
|
||||
/// </summary>
|
||||
public static DriverDataType? ToDriverDataType(this SparkplugDataType dataType) => dataType switch
|
||||
{
|
||||
SparkplugDataType.Int8 => DriverDataType.Int16,
|
||||
SparkplugDataType.Int16 => DriverDataType.Int16,
|
||||
SparkplugDataType.Int32 => DriverDataType.Int32,
|
||||
SparkplugDataType.Int64 => DriverDataType.Int64,
|
||||
SparkplugDataType.Uint8 => DriverDataType.UInt16,
|
||||
SparkplugDataType.Uint16 => DriverDataType.UInt16,
|
||||
SparkplugDataType.Uint32 => DriverDataType.UInt32,
|
||||
SparkplugDataType.Uint64 => DriverDataType.UInt64,
|
||||
SparkplugDataType.Float => DriverDataType.Float32,
|
||||
SparkplugDataType.Double => DriverDataType.Float64,
|
||||
SparkplugDataType.Boolean => DriverDataType.Boolean,
|
||||
SparkplugDataType.String => DriverDataType.String,
|
||||
SparkplugDataType.DateTime => DriverDataType.DateTime,
|
||||
SparkplugDataType.Text => DriverDataType.String,
|
||||
SparkplugDataType.Uuid => DriverDataType.String,
|
||||
SparkplugDataType.Bytes => DriverDataType.String,
|
||||
SparkplugDataType.File => DriverDataType.String,
|
||||
|
||||
SparkplugDataType.Int8Array => DriverDataType.Int16,
|
||||
SparkplugDataType.Int16Array => DriverDataType.Int16,
|
||||
SparkplugDataType.Int32Array => DriverDataType.Int32,
|
||||
SparkplugDataType.Int64Array => DriverDataType.Int64,
|
||||
SparkplugDataType.Uint8Array => DriverDataType.UInt16,
|
||||
SparkplugDataType.Uint16Array => DriverDataType.UInt16,
|
||||
SparkplugDataType.Uint32Array => DriverDataType.UInt32,
|
||||
SparkplugDataType.Uint64Array => DriverDataType.UInt64,
|
||||
SparkplugDataType.FloatArray => DriverDataType.Float32,
|
||||
SparkplugDataType.DoubleArray => DriverDataType.Float64,
|
||||
SparkplugDataType.BooleanArray => DriverDataType.Boolean,
|
||||
SparkplugDataType.StringArray => DriverDataType.String,
|
||||
SparkplugDataType.DateTimeArray => DriverDataType.DateTime,
|
||||
|
||||
// Unsupported v1 (design §3.5): DataSet/Template are deferred scope; PropertySet/
|
||||
// PropertySetList are PropertyValue-only metadata types that never legitimately appear as a
|
||||
// Metric's own datatype; Unknown is the proto's explicit "placeholder for future expansion"
|
||||
// (index 0). All five fall through here deliberately rather than being listed with a fake
|
||||
// mapping.
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="dataType"/> is one of the 13 <c>*Array</c> variants. Combine with
|
||||
/// <see cref="ToDriverDataType"/> (which already returns the *element* type for an array
|
||||
/// variant) to build an OPC UA ValueRank=1 array node per design §3.5.
|
||||
/// </summary>
|
||||
public static bool IsSparkplugArray(this SparkplugDataType dataType) => dataType switch
|
||||
{
|
||||
SparkplugDataType.Int8Array or SparkplugDataType.Int16Array or SparkplugDataType.Int32Array
|
||||
or SparkplugDataType.Int64Array or SparkplugDataType.Uint8Array or SparkplugDataType.Uint16Array
|
||||
or SparkplugDataType.Uint32Array or SparkplugDataType.Uint64Array or SparkplugDataType.FloatArray
|
||||
or SparkplugDataType.DoubleArray or SparkplugDataType.BooleanArray or SparkplugDataType.StringArray
|
||||
or SparkplugDataType.DateTimeArray => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Sparkplug B (P2): the vendored Eclipse Tahu schema is compiled here, in the one assembly all
|
||||
four MQTT projects reference. Message-only codegen (GrpcServices="None") — Sparkplug rides MQTT,
|
||||
there is no gRPC service, so no Grpc.Core.Api runtime reference is needed (Commons, this repo's
|
||||
other locally-compiled proto, takes one only because it generates service stubs).
|
||||
|
||||
Google.Protobuf is a SERIALIZATION dependency, not a transport one, so .Contracts stays
|
||||
transport-free: it still has no MQTTnet reference (dropped in Task 1) and Google.Protobuf's own
|
||||
graph is framework-only. Grpc.Tools is build-time (PrivateAssets=all) and flows to no consumer.
|
||||
|
||||
Build-platform note (inherited, not new): Grpc.Tools' bundled linux_arm64 protoc segfaults
|
||||
(exit 139) under Apple-Silicon Docker, which is why docker-dev/Dockerfile pins its build stage
|
||||
to the linux/amd64 platform. That pin already exists for the Commons proto; this project adds
|
||||
a second .proto to the same already-pinned build, not a new constraint. -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf"/>
|
||||
<PackageReference Include="Grpc.Tools">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\sparkplug_b.proto" GrpcServices="None"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe last-observed-value store backing <see cref="IReadable"/> for MQTT/Sparkplug B.
|
||||
/// MQTT is a subscribe-first protocol — the driver holds one live broker connection and values
|
||||
/// arrive pushed, not polled. But the OPC UA server still issues polled <c>IReadable.ReadAsync</c>
|
||||
/// batches, so this cache is the bridge: the subscription path (message handler) calls
|
||||
/// <see cref="Update"/> with the newest observed value per reference, and the read path serves
|
||||
/// the most recent one via <see cref="Read"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyed by <c>RawPath</c> — the v3 driver-reference identity (see
|
||||
/// <c>EquipmentTagRefResolver<TDef></c> and <see cref="MqttTagDefinition.Name"/>,
|
||||
/// which <b>is</b> the RawPath) — never a topic/JSON-path-derived key. The parameter is
|
||||
/// therefore named <c>rawPath</c> throughout rather than "topic" or "key" to keep that identity
|
||||
/// fact visible at every call site.
|
||||
/// </remarks>
|
||||
public sealed class LastValueCache
|
||||
{
|
||||
// OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value has
|
||||
// been observed yet" (mirrored by CalculationDriver before its first evaluation,
|
||||
// VirtualTagEngine for a freshly-materialised node, and OtOpcUaNodeManager /
|
||||
// AddressSpaceApplier for a just-deployed variable). MQTT is subscribe-first, so an unseen
|
||||
// RawPath is in exactly that state until its first publish arrives. Deliberately NOT
|
||||
// GoodNoData — that code is reserved (see NullHistorianDataSource, OtOpcUaNodeManager
|
||||
// HistoryRead paths) for "the historian window held no samples", a different question from
|
||||
// "has this live reference ever been observed".
|
||||
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new();
|
||||
|
||||
/// <summary>
|
||||
/// Record the newest observed value for <paramref name="rawPath"/>. Called from the MQTT
|
||||
/// subscription/message-handling path. A <c>null</c> or empty <paramref name="rawPath"/>
|
||||
/// is a no-op — never throws.
|
||||
/// </summary>
|
||||
/// <param name="rawPath">The RawPath identifying the tag.</param>
|
||||
/// <param name="snapshot">The newest observed value/quality/timestamps for that tag.</param>
|
||||
public void Update(string rawPath, DataValueSnapshot snapshot)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPath)) return;
|
||||
_values[rawPath] = snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the last observed value for <paramref name="rawPath"/>. Never throws: a
|
||||
/// <c>null</c>/empty <paramref name="rawPath"/> or a RawPath never observed both return a
|
||||
/// <see cref="BadWaitingForInitialData"/> snapshot rather than an exception, so a batch read
|
||||
/// covering many references degrades per-reference instead of failing the whole call.
|
||||
/// </summary>
|
||||
/// <param name="rawPath">The RawPath identifying the tag.</param>
|
||||
/// <returns>The last observed snapshot, or a BadWaitingForInitialData snapshot if none has been observed.</returns>
|
||||
public DataValueSnapshot Read(string rawPath)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(rawPath) && _values.TryGetValue(rawPath, out var snapshot))
|
||||
return snapshot;
|
||||
|
||||
return new DataValueSnapshot(null, BadWaitingForInitialData, null, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the MQTT / Sparkplug B driver with the <see cref="DriverFactoryRegistry"/>. The
|
||||
/// Host's <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the
|
||||
/// driver-instance bootstrapper then materialises <c>DriverInstance</c> rows of type
|
||||
/// <see cref="DriverTypeNames.Mqtt"/> into live <see cref="MqttDriver"/> instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Direct deserialization, no intermediate DTO.</b> The <c>DriverConfig</c> blob binds
|
||||
/// straight onto <see cref="MqttDriverOptions"/> — the same shape
|
||||
/// <see cref="MqttDriverProbe"/>, <c>MqttDriverBrowser</c> and
|
||||
/// <see cref="MqttDriver.ReinitializeAsync"/> already bind, and the shape the options record
|
||||
/// was designed for (every knob carries its own default, and <c>rawTags</c> is a plain
|
||||
/// <see cref="RawTagEntry"/> list needing no translation). Mirrors
|
||||
/// <c>OpcUaClientDriverFactoryExtensions</c>. <c>ModbusDriverFactoryExtensions</c>' separate
|
||||
/// DTO exists to service a legacy nullable-everything blob plus string→enum parsing this
|
||||
/// driver does with a converter instead; copying it here would add a <i>fourth</i> parse
|
||||
/// shape for one config, which is precisely the divergence
|
||||
/// <see cref="MqttJson.Options"/> exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Connection-free.</b> <see cref="CreateInstance"/> parses and constructs only — the
|
||||
/// <see cref="MqttDriver"/> constructor touches no network, and the registry contract
|
||||
/// forbids the factory from calling <c>InitializeAsync</c> itself (the driver host owns the
|
||||
/// retry semantics).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MqttDriverFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver type name — matches <c>DriverInstance.DriverType</c> values. Sourced from
|
||||
/// <see cref="DriverTypeNames.Mqtt"/> so the registration key can never drift from the
|
||||
/// constant the dispatch maps, the probe and the browser reference.
|
||||
/// </summary>
|
||||
public const string DriverTypeName = DriverTypeNames.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Register the MQTT factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct an
|
||||
/// <see cref="ILogger{MqttDriver}"/> per driver instance — without it the driver runs with no
|
||||
/// logger (standalone/test callers stay unchanged).
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory used to create per-instance loggers.</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">Stable logical id of the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The <c>DriverConfig</c> JSON blob for the instance.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the per-instance logger.</param>
|
||||
/// <returns>A configured, not-yet-connected <see cref="MqttDriver"/>.</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="driverInstanceId"/> or <paramref name="driverConfigJson"/> is blank.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">The config blob deserialised to <c>null</c>.</exception>
|
||||
/// <exception cref="JsonException">The config blob is not valid JSON for the options shape.</exception>
|
||||
public static MqttDriver CreateInstance(
|
||||
string driverInstanceId,
|
||||
string driverConfigJson,
|
||||
ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
// MqttJson.Options — the one shared instance (see its remarks). A local copy here is the
|
||||
// repo's documented systemic enum bug in the making.
|
||||
var options = JsonSerializer.Deserialize<MqttDriverOptions>(driverConfigJson, MqttJson.Options)
|
||||
?? throw new InvalidOperationException(
|
||||
$"MQTT driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
return new MqttDriver(options, driverInstanceId, loggerFactory?.CreateLogger<MqttDriver>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MQTTnet;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// CONNECT-handshake probe for the <see cref="MqttDriverOptions"/>-shaped driver config.
|
||||
/// Opens a bounded MQTT CONNECT against the configured broker; a CONNACK-accepted response
|
||||
/// (<c>MqttClientConnectResultCode.Success</c>) is the "device is answering" proof (green +
|
||||
/// latency). Refused/TLS/auth/timeout failures each surface a targeted message. Mirrors
|
||||
/// <c>ModbusDriverProbe</c>'s shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Reuses <see cref="MqttConnection.BuildClientOptions"/></b> for the connect (TLS,
|
||||
/// CA-pin, credentials, protocol-version mapping) rather than hand-rolling a second
|
||||
/// connect path — a duplicate would be a security divergence. A distinct client-id suffix
|
||||
/// (see <see cref="ProbeClientIdPrefix"/>) keeps a probe from colliding with the driver's
|
||||
/// own session: an MQTT broker disconnects an existing client when a new one CONNECTs
|
||||
/// with the same client id, so a probe that reused the driver's id would knock the
|
||||
/// running driver offline every time an operator clicked "Test connect".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A broker-rejected CONNACK is not a thrown exception.</b> Live-probed against
|
||||
/// MQTTnet 5.2.0.1603: <c>IMqttClient.ConnectAsync</c> returns a
|
||||
/// <c>MqttClientConnectResult</c> whose <c>ResultCode</c> carries the broker's CONNACK
|
||||
/// reason (e.g. <c>NotAuthorized</c>, <c>BadUserNameOrPassword</c>) — it does
|
||||
/// <b>not</b> throw for a rejected CONNACK. Only transport-level failures throw, and
|
||||
/// their shape varies by where the failure occurs: a closed/refused port throws
|
||||
/// <c>MqttCommunicationException</c> wrapping a <see cref="SocketException"/>; an
|
||||
/// untrusted broker certificate throws <c>MqttCommunicationException</c> wrapping an
|
||||
/// <see cref="AuthenticationException"/>; a broker that accepts the TCP handshake and
|
||||
/// then never answers CONNACK throws either <c>MqttConnectingFailedException</c>
|
||||
/// (wrapping "Connection closed") or a bare <see cref="OperationCanceledException"/>
|
||||
/// ("MQTT connect canceled"), inconsistently, depending on whether MQTTnet's own
|
||||
/// internal <c>Timeout</c> or the deadline token wins the race. Consequently this probe —
|
||||
/// like <see cref="MqttConnection.Classify"/> — never switches on exception type to
|
||||
/// detect a timeout; it checks the deadline token itself, which is authoritative
|
||||
/// regardless of which exception shape MQTTnet happened to throw.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MqttDriverProbe : IDriverProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Marks the transient probe identity in the broker's client-id/session logs — mirrors the
|
||||
/// browser's own <c>-browse-{guid8}</c> suffix so the two transient sessions are
|
||||
/// distinguishable in broker-side logs, and so a probe can never collide with (and knock
|
||||
/// offline) the driver's own live session.
|
||||
/// </summary>
|
||||
internal const string ProbeClientIdPrefix = "-probe-";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Mqtt;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
MqttDriverOptions? options;
|
||||
try
|
||||
{
|
||||
// MqttJson.Options — the one shared instance across factory / probe / driver / browser
|
||||
// (see its remarks): enums must round-trip by NAME or an AdminUI-authored config that
|
||||
// Test-connect accepts would fault the deployed driver.
|
||||
options = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, MqttJson.Options);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
|
||||
}
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Host) || options.Port is <= 0 or > 65535)
|
||||
{
|
||||
return new DriverProbeResult(false, "Config has no host/port to probe.", null);
|
||||
}
|
||||
|
||||
// Honour the config's own connectTimeoutSeconds (factory parity, mirrors
|
||||
// ModbusDriverProbe) — the caller's timeout is the fallback when the config omits it.
|
||||
var effectiveSeconds = options.ConnectTimeoutSeconds > 0
|
||||
? options.ConnectTimeoutSeconds
|
||||
: (int)Math.Ceiling(timeout.TotalSeconds);
|
||||
if (effectiveSeconds <= 0)
|
||||
{
|
||||
effectiveSeconds = 1;
|
||||
}
|
||||
|
||||
// Rebuild with the resolved timeout so BuildClientOptions' own WithTimeout(...) and this
|
||||
// method's deadline agree — both must expire at the same instant for the deadline check
|
||||
// below to be a reliable signal regardless of which of the two actually threw.
|
||||
var probeOptions = options with { ConnectTimeoutSeconds = effectiveSeconds };
|
||||
|
||||
MqttClientOptions clientOptions;
|
||||
try
|
||||
{
|
||||
clientOptions = MqttConnection.BuildClientOptions(
|
||||
probeOptions,
|
||||
ProbeClientIdPrefix + Guid.NewGuid().ToString("N")[..8],
|
||||
NullLogger.Instance);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let a configured Password reach the message — BuildClientOptions itself never
|
||||
// logs it, and no exception path through it can format credential values.
|
||||
return new DriverProbeResult(false, $"Config could not be used to build a connection: {ex.Message}", null);
|
||||
}
|
||||
|
||||
var target = $"{probeOptions.Host}:{probeOptions.Port}";
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(effectiveSeconds));
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||
|
||||
using var client = new MqttClientFactory().CreateMqttClient();
|
||||
|
||||
MqttClientConnectResult result;
|
||||
try
|
||||
{
|
||||
result = await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DriverProbeResult(false, Classify(target, ex, ct, deadline, effectiveSeconds), null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (result.ResultCode != MqttClientConnectResultCode.Success)
|
||||
{
|
||||
return new DriverProbeResult(false, ClassifyRejectedConnack(target, result.ResultCode), null);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return new DriverProbeResult(true, "MQTT CONNECT OK", sw.Elapsed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DisconnectBestEffortAsync(client, effectiveSeconds).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a broker-rejected CONNACK (<see cref="MqttClientConnectResult.ResultCode"/> not
|
||||
/// <c>Success</c> — never an exception, see the type remarks) to a targeted, credential-free
|
||||
/// message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Delegates to <see cref="MqttConnection.DescribeConnackRejection"/> so the "Test connect"
|
||||
/// button and the running driver report an identical rejection in identical words — they now
|
||||
/// both classify CONNACK, and disagreeing about it was the exact confusion the Task-13 live
|
||||
/// gate surfaced.
|
||||
/// </remarks>
|
||||
private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code)
|
||||
=> MqttConnection.DescribeConnackRejection(target, code);
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a thrown connect failure. Precedence mirrors
|
||||
/// <see cref="MqttConnection.Classify"/>: caller cancellation, then the deadline, then the
|
||||
/// exception's own shape — the deadline check is authoritative regardless of which exception
|
||||
/// type MQTTnet happened to throw (see the type remarks for why exception type alone is not
|
||||
/// reliable here).
|
||||
/// </summary>
|
||||
private static string Classify(
|
||||
string target,
|
||||
Exception ex,
|
||||
CancellationToken callerToken,
|
||||
CancellationTokenSource deadline,
|
||||
int effectiveSeconds)
|
||||
{
|
||||
if (callerToken.IsCancellationRequested)
|
||||
{
|
||||
return "Probe was cancelled.";
|
||||
}
|
||||
|
||||
if (deadline.IsCancellationRequested)
|
||||
{
|
||||
return $"Probe timed out after {effectiveSeconds}s.";
|
||||
}
|
||||
|
||||
// Walk to the root cause — MQTTnet wraps transport/TLS failures one or two levels deep.
|
||||
var root = ex;
|
||||
while (root.InnerException is not null)
|
||||
{
|
||||
root = root.InnerException;
|
||||
}
|
||||
|
||||
return root switch
|
||||
{
|
||||
SocketException se => $"Connect to {target} failed: {se.SocketErrorCode}",
|
||||
AuthenticationException => $"TLS handshake with {target} failed: {root.Message}",
|
||||
_ => $"Connect to {target} failed: {root.Message}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort teardown of a session this probe established. Failure here is not the
|
||||
/// probe's business — the CONNECT outcome already decided Ok/failure — and it must never be
|
||||
/// allowed to hang past the connect budget.
|
||||
/// </summary>
|
||||
private static async Task DisconnectBestEffortAsync(IMqttClient client, int timeoutSeconds)
|
||||
{
|
||||
if (!client.IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var disconnectDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
await client.DisconnectAsync(new MqttClientDisconnectOptions(), disconnectDeadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
}
|
||||
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