Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 898c7365c4 | |||
| 37f444b99c | |||
| 443b718158 | |||
| 02f2dafe2a | |||
| 10a6a37ca5 | |||
| a55b7e51ca | |||
| 14fe89c505 | |||
| 4c716242ac | |||
| 3cc8069150 | |||
| 48cbc26c34 | |||
| 9e7fa5d11f | |||
| 768fd87774 | |||
| e3e3f5fb04 | |||
| 861a1d1df0 | |||
| 9b30bdeb7a | |||
| cacc30a60d | |||
| 5629f3698d | |||
| 4a8c9badce | |||
| ded4c41798 | |||
| 0efee7413a | |||
| 650e27075f | |||
| 967e5140f1 | |||
| 726d6d198e | |||
| 132e7b63f6 | |||
| 13bd9820b0 | |||
| 1d90bf3611 | |||
| 50f88b938f | |||
| e3fea6e409 | |||
| 20be5416b9 | |||
| a05cff794c | |||
| a5a8710af5 | |||
| 6de782e0fd | |||
| 3d7e1226d2 | |||
| 47e9cd56ef | |||
| f22db5d801 | |||
| 984cc875e8 | |||
| 2f38b5b285 | |||
| d677ac7ad3 | |||
| 6bb0108d30 | |||
| 20100c36ad | |||
| 531a110ffc | |||
| 53ff041229 | |||
| 5b7fe9336e | |||
| eed6617784 | |||
| 3b75e1ac69 | |||
| 227cf8ee2b | |||
| 8d0f60ec51 | |||
| b13ff0be46 | |||
| e787aa572b | |||
| 963eec1b32 | |||
| 7a9caf141c | |||
| 88adec047b | |||
| 76e55fc112 | |||
| 4550486144 | |||
| 8dd9da7d4d |
@@ -58,3 +58,6 @@ lib/
|
||||
# Session working notes — never stage (pending.md's own hard rule; R2-12/U-4)
|
||||
/pending.md
|
||||
/current.md
|
||||
|
||||
# Subagent-driven-development git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -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
|
||||
@@ -168,13 +190,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 +233,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**.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -140,7 +158,7 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
<PackageVersion Include="xunit.v3" Version="1.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.2.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.3.0" />
|
||||
<!--
|
||||
LocalDb: embedded SQLite node-local cache + optional 2-node gRPC replication. The core
|
||||
package floors the native SQLitePCLRaw.lib.e_sqlite3 at 2.1.12, so consumers inherit the
|
||||
@@ -148,8 +166,8 @@
|
||||
-->
|
||||
<PackageVersion Include="ZB.MOM.WW.LocalDb" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.LocalDb.Replication" Version="0.1.3" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.2.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.2.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.3.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.1" />
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj" />
|
||||
<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" />
|
||||
@@ -42,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" />
|
||||
@@ -90,6 +98,11 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj" />
|
||||
<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" />
|
||||
@@ -104,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" />
|
||||
|
||||
@@ -100,9 +100,11 @@ The `-v` drops the SQL volume; remove it to keep ConfigDb state across restarts.
|
||||
|
||||
## Failover smoke
|
||||
|
||||
1. Watch the Traefik dashboard at `http://localhost:8089`. Both `central-1` and `central-2` should be listed as healthy in the `otopcua-admin` service.
|
||||
2. `docker compose -f docker-dev/docker-compose.yml stop central-1` — `central-2` should pick up the admin role-leader within ~15 s (Akka split-brain stable-after). Traefik will route traffic to `central-2` once its `/health/active` returns 200.
|
||||
3. `docker compose -f docker-dev/docker-compose.yml start central-1` — `central-1` rejoins as a follower; `central-2` keeps the leader role until something disturbs it.
|
||||
1. Watch the Traefik dashboard at `http://localhost:8089`. Exactly ONE of `central-1` / `central-2` is UP in the `otopcua-admin` service — the admin role-leader. The standby is DOWN by design: `/health/active` answers 503 on it, which is how Traefik pins browser traffic to the leader. (Before `ZB.MOM.WW.Health` 0.2.1 the standby answered 200, so both showed UP and this pinning silently never worked — see `lmxopcua#494`.)
|
||||
2. `docker compose -f docker-dev/docker-compose.yml stop central-1` — `central-2` picks up the admin role-leader within ~15 s (Akka split-brain stable-after) and Traefik flips it to UP. Verified 2026-07-24: the swap completed inside 20 s with **no gap** — `http://localhost:9200/` answered 200 continuously across the failover.
|
||||
3. `docker compose -f docker-dev/docker-compose.yml start central-1` — `central-1` rejoins and **reclaims** the admin role-leader, and Traefik flips back. It does not stay with `central-2`: Akka's `RoleLeader` is the lowest-address member, so the outcome is deterministic rather than sticky.
|
||||
|
||||
> **Cold-boot caveat.** Starting both central nodes simultaneously from stopped can leave each self-forming its own 1-member cluster (self-first seed lists), in which case BOTH are their own role-leader and both answer `/health/active` 200. Check `entries["akka"].data.memberCount` on `/health/ready` — it should read 2. Restarting either node makes it join the survivor.
|
||||
|
||||
## Resource limits & dev logging
|
||||
|
||||
|
||||
@@ -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)
|
||||
--
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
+11
-2
@@ -29,6 +29,8 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
|
||||
| [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 |
|
||||
| [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 +42,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 +71,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 |
|
||||
|
||||
@@ -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.**
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ OpcUaClient, Historian.Gateway (`src/Drivers/`).
|
||||
|
||||
## 2. Document map
|
||||
|
||||
**Program:** this file.
|
||||
**Program:** this file (architecture / shared contract / build order).
|
||||
**Progress tracker (Waves 0–2):** [`2026-07-24-driver-expansion-tracking.md`](2026-07-24-driver-expansion-tracking.md)
|
||||
— per-deliverable status + links to each design doc and implementation plan.
|
||||
|
||||
**Research reports** (`docs/research/drivers/`, index: [`README.md`](../research/drivers/README.md)):
|
||||
- [`mtconnect-agent.md`](../research/drivers/mtconnect-agent.md) ·
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# Driver-expansion — Waves 0–2 tracking
|
||||
|
||||
> **Living status tracker** for the driver-expansion program (Waves 0, 1, 2). One row per
|
||||
> deliverable, each pointing at its **design doc**, its **implementation plan** (once written),
|
||||
> and its current **status**. The authoritative *architecture* index is the program design doc;
|
||||
> this file is the authoritative *progress* index.
|
||||
>
|
||||
> **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.
|
||||
|
||||
## Legend
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| ✅ **Done** | Merged to master, tests green. |
|
||||
| 🟡 **Live gate open** | Code merged; a live `/run` or hardware-gated verification still outstanding. |
|
||||
| 📝 **Plan ready** | Executable implementation plan (`*-implementation.md` + `.tasks.json`) written; not yet built. |
|
||||
| 📐 **Design only** | Design doc exists; **no** implementation plan yet — writing-plans is the next step. |
|
||||
| ⛔ **Not started** | No design, no plan. |
|
||||
|
||||
**Doc types** (per the writing-plans skill): a *design* states architecture, decisions, and risks;
|
||||
an *implementation plan* is the bite-sized, TDD, file-path-level task list the subagent-driven
|
||||
executor runs off, with a co-located `.tasks.json` for resume. A deliverable is only buildable once
|
||||
it reaches 📝.
|
||||
|
||||
## Summary
|
||||
|
||||
| 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) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **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) | ✅ **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
|
||||
> env-gated live. Both are out of this file's scope until they're pulled forward.
|
||||
|
||||
---
|
||||
|
||||
## Executing a plan (git worktree + subagent-per-task)
|
||||
|
||||
Each 📝 plan is executed with the **subagent-driven-development** skill: it sets up an isolated
|
||||
**git worktree** first (via `using-git-worktrees`), then dispatches a **fresh subagent per task**,
|
||||
running the classification-driven review chain (`trivial` = implement only … `high-risk` =
|
||||
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:**
|
||||
|
||||
| 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` |
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 — Universal Discover-backed browser 🟡
|
||||
|
||||
**What it is.** One generic `DiscoveryDriverBrowser` (+ `CapturingAddressSpaceBuilder`,
|
||||
`CapturedTreeBrowseSession`, `BrowserSessionService` fallback, and the
|
||||
`ITagDiscovery.SupportsOnlineDiscovery` gate) that turns any driver's `ITagDiscovery.DiscoverAsync`
|
||||
into an AdminUI browse tree. It is the **Wave-0 gate**: every browsable new driver depends on this
|
||||
seam, and it retrofits browse to the already-shipped AbCip / TwinCAT / FOCAS drivers for near-zero
|
||||
marginal cost.
|
||||
|
||||
- **Design:** [`2026-07-15-universal-discovery-browser-design.md`](2026-07-15-universal-discovery-browser-design.md)
|
||||
- **Implementation plan:** [`2026-07-15-universal-discovery-browser-implementation.md`](2026-07-15-universal-discovery-browser-implementation.md) · [`.tasks.json`](2026-07-15-universal-discovery-browser-implementation.md.tasks.json)
|
||||
- **Status: 🟡 code-complete + merged, live gate open.**
|
||||
- Merged to master — `056887d6` (*Merge feat/universal-discovery-browser — Wave-0 universal Discover-backed browser*), 2026-07-15.
|
||||
- Implementation plan: **19 / 19 tasks completed.**
|
||||
- Lit up AbCip / TwinCAT / FOCAS pickers with zero per-driver browse code.
|
||||
- **Outstanding:** the full tree-render live `/run` gate is **fixture-blocked** — tracked as **Gitea #468**. Complete this before leaning on browse in Wave 2/3.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
|
||||
- **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), 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:** 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
|
||||
- **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`).
|
||||
- **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
|
||||
central SQL Server (it hosts `ConfigDb`).
|
||||
- **Effort:** S–M.
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — strategic telemetry / UNS pair 📝
|
||||
|
||||
Both CI-simulatable on the shared docker host, no hardware.
|
||||
|
||||
### 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; 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 — ✅ **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, 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 + `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 BACnet (MTConnect
|
||||
shipped its own Task-21 live `/run` gate independently — see the plan file).
|
||||
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. **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.
|
||||
@@ -0,0 +1,729 @@
|
||||
# Modbus RTU Driver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a Modbus **RTU-over-TCP** transport (RTU CRC-16 framing tunnelled to a serial→Ethernet gateway) to the existing `ModbusDriver`, selected by a new `Transport` config field — reusing every codec/planner/health/materialisation surface unchanged.
|
||||
|
||||
**Architecture:** Purely additive behind the existing `IModbusTransport` seam. The application protocol (function codes, codecs, planner, coalescing, write path, probe, materialisation, HistoryRead) is byte-for-byte identical across TCP and RTU — only the wire framing differs (`[addr][PDU][CRC-16]`, no MBAP, no TxId). Work = a CRC-16 helper + a length-less FC-aware framer + one new socket transport that composes a socket lifecycle extracted from `ModbusTcpTransport` + a transport-mode selector on the config + one AdminUI field. **Zero** changes to codecs/planner/health.
|
||||
|
||||
**Tech Stack:** existing `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (+ `.Contracts`, `.Addressing`), xUnit + Shouldly, `pymodbus[simulator]==3.13.0` Docker fixture (RTU-framed-TCP profile; stdlib fallback server modelled on `exception_injector.py`), Blazor `ModbusDriverForm.razor` (AdminUI). No new NuGet dependency.
|
||||
|
||||
**Source design:** docs/plans/2026-07-15-modbus-rtu-driver-design.md
|
||||
**Program design:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §7 fixtures) — this is **Wave 1**.
|
||||
**Progress tracker:** docs/plans/2026-07-24-driver-expansion-tracking.md
|
||||
|
||||
## Scope (P1 shippable v1 only)
|
||||
|
||||
RTU-over-TCP via a serial→Ethernet gateway (Moxa/Digi/Lantronix), RTU CRC-16 (poly `0xA001`) + FC-aware length framing, a `Transport` selector (`Tcp` | `RtuOverTcp`) on the Modbus config, the `rtu_over_tcp` pymodbus docker profile, and the AdminUI selector. Read **and** write supported (same as TCP).
|
||||
|
||||
## Deferred / out of scope
|
||||
|
||||
- **Direct-serial transport** (`ModbusRtuTransport` / `System.IO.Ports`) — descoped (user, 2026-07-15). RTU buses are reached exclusively via a serial→Ethernet gateway. Design record kept in **design §2b / §9 P2**; the `ModbusRtuFraming` built here is byte-stream-agnostic so a future serial leg would reuse it unchanged. Do **not** number-squat an `Rtu` enum member for it.
|
||||
- **Modbus ASCII** — a third framing (`:`-delimited hex + LRC); would be another `IModbusTransport` if ever needed (design §2c). Not planned.
|
||||
- **Per-tag `TagConfig` changes** — none. `ModbusTagDefinition`/`ModbusTagDto` already carry the per-tag `UnitId` override that makes an RS-485 multi-drop bus work; the read planner already refuses to coalesce across UnitIds. Additions are **driver-level only** (design §4).
|
||||
- **`ModbusDriver.BuildSlaveHostName` change** — NOT needed. It already emits `host:port/unit{n}`, the correct per-slave resilience key for a gateway (design §6). The design §1 table listed it only for the descoped serial (COM) case.
|
||||
|
||||
## Cross-cutting rules (program §3.1 — mandatory)
|
||||
|
||||
- **Enum-serialization trap** — `Transport` MUST round-trip as a **name string**, never a number, on both the AdminUI serializer (`ModbusDriverForm._jsonOpts` already carries `JsonStringEnumConverter`) and the factory (DTO field typed `string?`, parsed via the existing `ParseEnum<T>` — mirror `Family`/`MelsecSubFamily`; **do not** type the DTO field as the enum). Guarded by an explicit `"transport":"RtuOverTcp"` string assertion (design §5).
|
||||
- **Per-op deadline (R2-01)** — the RTU transport MUST run every transaction under a linked-CTS `CancelAfter(Options.Timeout)` — a frozen gateway must never wedge a poll. There is **no MBAP length field to trust**; FC-aware sizing is the only length signal, so getting it right (or timing out) is the top correctness risk (design §7).
|
||||
- **Single-flight mandatory on RTU** — no TxId means at most one transaction on the bus at a time. Keep the `_gate` `SemaphoreSlim` in the new transport.
|
||||
- **Ctor is connection-free** — the transport constructor must not connect; all I/O happens in `ConnectAsync`/`SendAsync`.
|
||||
- **Live-verify discipline** — the picker/editor + deploy path get a docker-dev `/run` before trust (Task 10).
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Add the `ModbusTransportMode` enum
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 1, Task 3
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs`
|
||||
|
||||
The enum lives in `.Contracts` (backend-dep-free), namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` — same namespace as `ModbusDriverOptions`.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportModeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tcp_is_the_default_zero_member()
|
||||
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
|
||||
|
||||
[Fact]
|
||||
public void Exactly_two_members_ship_in_v1()
|
||||
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL** (type does not exist / does not compile):
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportModeTests"`
|
||||
|
||||
**Step 3 — minimal impl:**
|
||||
```csharp
|
||||
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,
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ModbusCrc` — CRC-16 (poly 0xA001) helper + golden vectors
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0, Task 3
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs`
|
||||
|
||||
CRC-16/MODBUS: reflected poly `0xA001`, init `0xFFFF`, appended **low byte first**. This is the top-of-stack of the length-less framer — golden vectors are the whole point.
|
||||
|
||||
**Step 1 — failing test** (table-driven against published Modbus CRC vectors):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusCrcTests
|
||||
{
|
||||
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
|
||||
// on the wire it is appended low-byte-first.
|
||||
[Theory]
|
||||
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
|
||||
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
|
||||
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
|
||||
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
|
||||
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
|
||||
=> ModbusCrc.Compute(frame).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void AppendLowByteFirst_writes_lo_then_hi()
|
||||
{
|
||||
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
var withCrc = ModbusCrc.Append(body);
|
||||
withCrc.Length.ShouldBe(body.Length + 2);
|
||||
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
|
||||
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusCrcTests"`
|
||||
|
||||
**Step 3 — minimal impl** (`ModbusCrc.cs`): a `static ushort Compute(ReadOnlySpan<byte>)` (init `0xFFFF`, xor byte, 8× shift-right, xor `0xA001` on carry) and `static byte[] Append(ReadOnlySpan<byte> body)` returning `body + [crc & 0xFF, crc >> 8]`. ~30 lines. If a golden vector disagrees, fix the CRC math — never the vector.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `ModbusRtuFraming` — ADU build + FC-aware length-less deframe
|
||||
|
||||
**Classification:** high-risk (framing)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs`
|
||||
|
||||
The one genuinely new correctness risk (design §2a/§7): RTU frames carry **no length field**, so response size is parsed from the function code. Read `addr(1)`+`fc(1)`, then: **exception** (`fc & 0x80`) → `excCode(1)+CRC(2)`; **reads FC01/02/03/04** → `byteCount(1)` then `byteCount + CRC(2)`; **write echoes FC05/06/15/16** → fixed `4 + CRC(2)`. Validate trailing CRC, strip `addr`+`CRC`, return the bare PDU `[fc, ...data]`. CRC mismatch / truncation → `ModbusTransportDesyncException`; exception PDU (after CRC-validate) → `ModbusException`. Test directly against an in-memory `MemoryStream` of canned bytes.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusRtuFramingTests
|
||||
{
|
||||
private static MemoryStream Canned(params byte[] frame) => new(frame);
|
||||
|
||||
[Fact]
|
||||
public void BuildAdu_prefixes_unit_and_appends_crc()
|
||||
{
|
||||
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
|
||||
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC03_returns_bare_pdu()
|
||||
{
|
||||
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
|
||||
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
|
||||
var frame = ModbusCrc.Append(body);
|
||||
await using var s = Canned(frame);
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_exception_frame_throws_ModbusException()
|
||||
{
|
||||
// addr=01 fc=0x83 exc=0x02 + CRC
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
|
||||
await using var s = Canned(frame);
|
||||
var ex = await Should.ThrowAsync<ModbusException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
ex.FunctionCode.ShouldBe((byte)0x03);
|
||||
ex.ExceptionCode.ShouldBe((byte)0x02);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_bad_crc_throws_desync()
|
||||
{
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
frame[^1] ^= 0xFF; // corrupt CRC high byte
|
||||
await using var s = Canned(frame);
|
||||
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
|
||||
{
|
||||
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
|
||||
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
|
||||
await using var s = Canned(frame);
|
||||
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
|
||||
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuFramingTests"`
|
||||
|
||||
**Step 3 — minimal impl** (`ModbusRtuFraming.cs`): `static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)` = `ModbusCrc.Append([unitId, ..pdu])`; `static async Task<byte[]> ReadResponsePduAsync(Stream, byte expectedUnit, CancellationToken)` doing the FC-aware read described above with a `ReadExactlyAsync` helper (mirror `ModbusTcpTransport.ReadExactlyAsync`). Reuse `ModbusException` + `ModbusTransportDesyncException` (both already in the project). Keep framing byte-stream-agnostic (no socket knowledge) so a future serial transport can reuse it.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `ModbusSocketLifecycle` from `ModbusTcpTransport` (behaviour-preserving)
|
||||
|
||||
**Classification:** high-risk (transport / concurrency)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 0, Task 1, Task 2
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs`
|
||||
|
||||
Mechanical refactor: move the IPv4-preference connect (`ConnectAsync`), `EnableKeepAlive`/`ClampToWholeSeconds`, `ConnectWithBackoffAsync`, `TearDownAsync`, and the `_lastSuccessUtc`/idle-disconnect tracking out of `ModbusTcpTransport` into a reusable `ModbusSocketLifecycle` that exposes the current `NetworkStream`. `ModbusTcpTransport` keeps its MBAP `SendOnceAsync` and composes the lifecycle. **Behaviour must be byte-for-byte unchanged** — the existing `ModbusTcpReconnectTests` + `ModbusConnectionOptionsTests` are the regression net; run the whole Modbus.Tests suite green before committing.
|
||||
|
||||
**Step 1 — failing test** (pins the extracted surface; `ClampToWholeSeconds` moves with it):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusSocketLifecycleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
|
||||
[InlineData(2.0, 2)]
|
||||
[InlineData(-5.0, 1)] // negative clamps to 1
|
||||
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
|
||||
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_to_dead_port_surfaces_socket_failure()
|
||||
{
|
||||
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
|
||||
autoReconnect: false);
|
||||
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
|
||||
life.ConnectAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusSocketLifecycleTests"`
|
||||
|
||||
**Step 3 — minimal impl:** create `ModbusSocketLifecycle` holding `_host/_port/_timeout/_autoReconnect/_keepAlive/_idleDisconnect/_reconnect`, the `TcpClient`/`NetworkStream`, and `_lastSuccessUtc`; expose `ConnectAsync`, `ConnectWithBackoffAsync`, `TearDownAsync`, `Stream` (current), `MarkSuccess()`, `ShouldReconnectForIdle()`, and the `static int ClampToWholeSeconds`. Re-point `ModbusTcpTransport` to delegate connect/reconnect/teardown/idle to it while keeping MBAP `SendOnceAsync` in place. Move (don't duplicate) `ClampToWholeSeconds` — update its one internal caller/test reference.
|
||||
|
||||
**Step 4 — run & expect PASS** — then run the **whole** suite to prove no regression:
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests`
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ModbusRtuOverTcpTransport` — RTU framing over the socket lifecycle
|
||||
|
||||
**Classification:** high-risk (framing / transport / concurrency)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs`
|
||||
|
||||
`IModbusTransport` implementation: composes `ModbusSocketLifecycle` (Task 3) for connect/reconnect/keepalive/idle and `ModbusRtuFraming` (Task 2) for send/receive. Delta from `ModbusTcpTransport`: CRC framing instead of MBAP, **no TxId**. Keep the `_gate` `SemaphoreSlim` single-flight (mandatory — no TxId), the single reconnect-retry shape, and a linked-CTS `CancelAfter(Options.Timeout)` per-op deadline (design §7, R2-01). **Seam choice for unit test:** add an `internal` test ctor accepting a pre-connected `Stream` (an in-memory duplex fake) that bypasses `ConnectAsync` — this is "the fake one level below the `IModbusTransport` fakes" the design §8 calls for, and lets the send/receive orchestration + single-flight + deadline be tested with no socket.
|
||||
|
||||
**Step 1 — failing test** (duplex fake: canned response stream + capture of written request bytes):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusRtuOverTcpTransportTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
|
||||
{
|
||||
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
|
||||
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
|
||||
var fake = new CapturingDuplexStream(response);
|
||||
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
|
||||
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
|
||||
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
|
||||
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
|
||||
{
|
||||
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
|
||||
var fake = new CapturingDuplexStream(response);
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
|
||||
await Should.ThrowAsync<ModbusException>(
|
||||
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
|
||||
{
|
||||
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
|
||||
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
|
||||
await Should.ThrowAsync<ModbusTransportDesyncException>(
|
||||
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
```
|
||||
(The `CapturingDuplexStream` test double — a `Stream` that records writes and replays `respondBytes` on read, or blocks forever when `stall` — lives in the test file.)
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuOverTcpTransportTests"`
|
||||
|
||||
**Step 3 — minimal impl:** production ctor takes the same connection params as `ModbusTcpTransport` and builds a `ModbusSocketLifecycle`; `internal static ForTest(Stream, TimeSpan)` injects a pre-connected stream. `SendAsync`: `_gate.WaitAsync`; idle-reconnect check (skipped in test seam); write `ModbusRtuFraming.BuildAdu(unitId, pdu)`, flush, `ModbusRtuFraming.ReadResponsePduAsync(...)` under a linked `CancelAfter(_timeout)`; timeout-vs-caller-cancel distinction + teardown mirror `ModbusTcpTransport.SendOnceAsync`; single reconnect-retry on socket-level failure.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `ModbusTransportFactory.Create` — switch on `Transport`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 6
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs`
|
||||
|
||||
One place that maps `ModbusDriverOptions` → the right `IModbusTransport`, consumed by both the driver default closure (Task 7) and the probe (Task 7). `Tcp` → `ModbusTcpTransport`, `RtuOverTcp` → `ModbusRtuOverTcpTransport`; both get `Host/Port/Timeout/AutoReconnect/KeepAlive/IdleDisconnect/Reconnect`.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tcp_mode_builds_tcp_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void RtuOverTcp_mode_builds_rtu_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void Default_options_build_tcp_transport()
|
||||
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
}
|
||||
```
|
||||
(Depends on Task 6's `Transport` property existing on `ModbusDriverOptions` — sequence Task 6 or add the property first. If Task 5 lands before Task 6, add the property in this task instead.)
|
||||
|
||||
**Step 2 — run & expect FAIL.**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportFactoryTests"`
|
||||
|
||||
**Step 3 — minimal impl:** `static IModbusTransport Create(ModbusDriverOptions o)` = `switch (o.Transport) { RtuOverTcp => new ModbusRtuOverTcpTransport(...), _ => new ModbusTcpTransport(...) }`, passing the shared connection params.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add `Transport` to options + DTO + factory (string-enum guard)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 5
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs`
|
||||
|
||||
Add `public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;` to `ModbusDriverOptions`. On the DTO: add `public string? Transport { get; init; }` (typed **`string?`**, per the Modbus factory pattern — design §5) and map it with the existing `ParseEnum<ModbusTransportMode>(...)` helper, defaulting to `Tcp` when null. The guard test asserts the string wire form.
|
||||
|
||||
**Step 1 — failing test:**
|
||||
```csharp
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportConfigRoundTripTests
|
||||
{
|
||||
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
|
||||
private static readonly JsonSerializerOptions _adminOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Transport_serializes_as_a_string_not_a_number()
|
||||
{
|
||||
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
|
||||
var json = JsonSerializer.Serialize(opts, _adminOpts);
|
||||
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
|
||||
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_binds_RtuOverTcp_from_string_config()
|
||||
{
|
||||
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
|
||||
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_defaults_Transport_to_Tcp_when_omitted()
|
||||
{
|
||||
const string json = """{ "host": "10.0.0.10" }""";
|
||||
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportConfigRoundTripTests"`
|
||||
|
||||
**Step 3 — minimal impl:** add the `Transport` property (options) + DTO `string?` field + factory mapping `Transport = dto.Transport is null ? ModbusTransportMode.Tcp : ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport")`.
|
||||
|
||||
**Step 4 — run & expect PASS.**
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire the driver default closure + probe to `ModbusTransportFactory`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs`
|
||||
|
||||
Replace the driver ctor's hardcoded `new ModbusTcpTransport(...)` default closure with `o => ModbusTransportFactory.Create(o)`. In the probe, replace the hardcoded `new ModbusTcpTransport(host, port, timeout, autoReconnect: false)` (line ~77) with a factory build carrying `Transport` (parse it into `ProbeTarget` from the DTO), `autoReconnect: false`. Now an `RtuOverTcp`-authored config probes over RTU framing — matching how it will actually run.
|
||||
|
||||
**Step 1 — failing test** (the default closure honours the mode; the probe carries it):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
public sealed class ModbusTransportWiringTests
|
||||
{
|
||||
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
|
||||
{
|
||||
using var driver = new ModbusDriver(opts, "wire-test");
|
||||
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
|
||||
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
return factory(opts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
|
||||
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
|
||||
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
|
||||
|
||||
[Fact]
|
||||
public void Default_closure_builds_tcp_transport_for_Tcp()
|
||||
=> BuildDefaultTransport(new ModbusDriverOptions())
|
||||
.ShouldBeOfType<ModbusTcpTransport>();
|
||||
}
|
||||
```
|
||||
(If `_transportFactory` reflection proves brittle, assert instead that a `RtuOverTcp` driver initialised against an unreachable host degrades without ever constructing an MBAP frame — but the closure-reflection test is the tightest.)
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportWiringTests"`
|
||||
|
||||
**Step 3 — minimal impl:** driver ctor default = `?? (o => ModbusTransportFactory.Create(o))`; probe: add `ModbusTransportMode Transport` to `ProbeTarget`, parse `dto.Transport` via `ParseEnum`, and build the probe transport through `ModbusTransportFactory.Create(new ModbusDriverOptions { Host, Port, Timeout, Transport, AutoReconnect = false })`.
|
||||
|
||||
**Step 4 — run & expect PASS** (+ run the full Modbus.Tests suite green).
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: AdminUI — `Transport` selector on `ModbusDriverForm`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 7
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs`
|
||||
|
||||
Add a `Transport` `<InputSelect>` to the **Protocol** panel (design's target `ModbusDriverPage.razor` is retired; `ModbusDriverForm.razor` is the live host — it serializes `ModbusDriverOptions` directly through `_jsonOpts`, which already carries `JsonStringEnumConverter`, so `Transport` emits as a name string automatically; `Transport` is **not** an endpoint key so it is not stripped by `GetConfigJson`). Add `public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;` to `FormModel`, map it in `FromOptions`/`ToOptions`. Host/Port stay on `ModbusDeviceForm` — a `RtuOverTcp` gateway uses the same `host:port` shape. Extend the existing `ModbusDriverFormModelTests` (round-trip + string-enum guard).
|
||||
|
||||
**Step 1 — failing test** (append to `ModbusDriverFormModelTests`):
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Round_trip_preserves_Transport_mode()
|
||||
{
|
||||
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
|
||||
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
|
||||
var back = ModbusDriverForm.FormModel.FromOptions(
|
||||
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
|
||||
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
|
||||
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — run & expect FAIL:**
|
||||
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverFormModelTests"`
|
||||
|
||||
**Step 3 — minimal impl:** add the `Transport` `FormModel` property + `FromOptions`/`ToOptions` mapping, and an `<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync">` looping `Enum.GetValues<ModbusTransportMode>()` in the Protocol panel, with a form-text hint: "RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode."
|
||||
|
||||
**Step 4 — run & expect PASS.** (AdminUI has no bUnit — the razor binding itself is proven live in Task 10; this model test is the reflection substitute per the AdminUI live-verify memory.)
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Docker `rtu_over_tcp` fixture + RTU-over-TCP integration test
|
||||
|
||||
**Classification:** standard (new component / multi-file)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json`
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs`
|
||||
- Modify (only if the pymodbus RTU-framer confirmation fails): `Docker/Dockerfile` + `Docker/rtu_over_tcp_server.py`
|
||||
|
||||
**FIRST sub-step — confirm the unknown (design §8 #1):** verify the pymodbus simulator actually exposes the RTU framer on a TCP server. The checked-in `profiles/standard.json` already sets `server_list.srv.framer: "socket"`, so the knob exists; the RTU profile sets `"framer": "rtu"` (comm stays `"tcp"`). Bring it up and probe with the new driver:
|
||||
```bash
|
||||
lmxopcua-fix sync modbus
|
||||
lmxopcua-fix up modbus rtu_over_tcp
|
||||
# then run the integration test (below) against MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021
|
||||
```
|
||||
If pymodbus 3.13's simulator does **not** honour `framer=rtu` on a TCP server (garbled/MBAP-framed replies), **fall back** to a stdlib `rtu_over_tcp_server.py` modelled on the existing `exception_injector.py` (same asyncio TCP-server shape, but frame with `[addr][PDU][CRC-16]` via a Python CRC-16/MODBUS instead of the MBAP header) + a `COPY rtu_over_tcp_server.py` line in the Dockerfile and a `command:` override on the service. Record which path was taken in the test-class summary.
|
||||
|
||||
Fixture details: bind host port **`5021`** (NOT the shared `:5020` — the `rtu_over_tcp` service must co-run with `standard` and sidestep the shared-port stale-container trap). Add `labels: { project: lmxopcua }` per the program fixture convention (existing services carry none — `lmxopcua-fix sync` normally owns the label host-side, but add it in-file for this service so it is discoverable). `ModbusRtuOverTcpFixture` mirrors `ModbusSimulatorFixture` but reads `MODBUS_RTU_SIM_ENDPOINT` (default `10.100.0.35:5021`) with the same skip-clean pattern.
|
||||
|
||||
**Step 1 — failing test** (`ModbusRtuOverTcpTests.cs` — read + write round-trip over RTU-over-TCP):
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
[Collection(ModbusRtuOverTcpCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "RtuOverTcp")]
|
||||
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1,
|
||||
Transport = ModbusTransportMode.RtuOverTcp,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([
|
||||
new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, Address: 5,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
]),
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, "modbus-rtu-int");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
|
||||
|
||||
results[0].StatusCode.ShouldBe(0u); // Good
|
||||
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host, Port = sim.Port, UnitId = 1,
|
||||
Transport = ModbusTransportMode.RtuOverTcp,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
RawTags = ModbusRawTags.Entries([
|
||||
new ModbusTagDefinition("HR200", ModbusRegion.HoldingRegisters, Address: 200,
|
||||
DataType: ModbusDataType.UInt16, Writable: true),
|
||||
]),
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, "modbus-rtu-int-w");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writes = await driver.WriteAsync([new WriteRequest("HR200", (ushort)4242)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writes[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
|
||||
back[0].Value.ShouldBe((ushort)4242);
|
||||
}
|
||||
}
|
||||
```
|
||||
(Seed `rtu_over_tcp.json` with the same `HR[0..31]=address-as-value` + `HR[200..209]=scratch` layout as `standard.json` so these addresses resolve.)
|
||||
|
||||
**Step 2 — run & expect FAIL / SKIP** without the fixture, then bring it up:
|
||||
```bash
|
||||
lmxopcua-fix sync modbus && lmxopcua-fix up modbus rtu_over_tcp
|
||||
MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests --filter "FullyQualifiedName~ModbusRtuOverTcpTests"
|
||||
```
|
||||
|
||||
**Step 3 — minimal impl:** the `rtu_over_tcp.json` profile + compose service (+ stdlib server fallback only if the framer confirmation failed) + the fixture class. Iterate until both facts hold on the wire.
|
||||
|
||||
**Step 4 — run & expect PASS** against the live fixture.
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Live `/run` verification on docker-dev
|
||||
|
||||
**Classification:** standard (live gate)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify (docs only): `docs/plans/2026-07-24-driver-expansion-tracking.md` (record Wave-1 Modbus-RTU live-gate result)
|
||||
|
||||
No new code — the live-verify discipline (program §3.1) proves the Razor binding + deploy path that unit tests can't. Against the local docker-dev rig (AdminUI `http://localhost:9200`, login disabled — drive it yourself; do not wait for the user to sign in):
|
||||
|
||||
1. Bring up the `rtu_over_tcp` fixture (`lmxopcua-fix up modbus rtu_over_tcp`, host `:5021`).
|
||||
2. In `/raw`, author a Modbus device pointing Host/Port at the docker host `:5021`, and set the driver **Transport = RtuOverTcp** in the driver-config modal (the Task 8 selector). Run **Test Connect** — expect green (the probe now runs FC03 over RTU framing).
|
||||
3. Author a holding-register raw tag (e.g. `HR5`), reference it into a UNS equipment, and deploy.
|
||||
4. Read it back via Client.CLI against the local server, or the `/uns` value panel:
|
||||
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath-of-HR5>"`
|
||||
— expect Good quality + the seeded value.
|
||||
5. Write to a writable register (`HR200`) via Client.CLI `write` and confirm it round-trips.
|
||||
6. Record PASS/FAIL + evidence in the tracking doc.
|
||||
|
||||
**Step 5 — commit:**
|
||||
`git commit -am "docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`
|
||||
|
||||
---
|
||||
|
||||
## Ordering summary
|
||||
|
||||
Framer/CRC unit layer (Tasks 0–2) → transport (Tasks 3–4) → factory + config + driver/probe wiring (Tasks 5–7) → AdminUI selector (Task 8) → docker integration fixture (Task 9) → live `/run` (Task 10). Tasks 1 & 3 can run alongside Task 0; Task 5 alongside Task 6; Task 7 alongside Task 8. Commit after every task. DRY: `ModbusRtuFraming` and `ModbusSocketLifecycle` are each written once and composed; no codec/planner/health code is touched. YAGNI: no direct-serial, no ASCII, no per-tag config, no `BuildSlaveHostName` change.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]},
|
||||
{"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]},
|
||||
{"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]},
|
||||
{"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
# MQTT / Sparkplug B Driver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Ship a standard Equipment-kind `Mqtt` driver that ingests plain MQTT (P1) then Sparkplug B (P2) into the OtOpcUa dual-namespace address space, with a bespoke observation browser, typed AdminUI editor, and TLS+auth-enforced fixtures.
|
||||
**Architecture:** Three new `net10.0` projects mirror the OpcUaClient triad — `.Contracts` (transport-free options/DTOs/parser/enums), `.Driver` (subscribe-first `IDriver` holding one live MQTTnet-5 client for its whole lifetime, raising `OnDataChange` from the receive callback, with a hand-rolled reconnect loop since v5 dropped `ManagedMqttClient`), and `.Browser` (a passive `#`/birth observation-window `IBrowseSession`). Sparkplug B decodes vendored Eclipse Tahu protobuf via `Google.Protobuf`/`Grpc.Tools` over that same single client — no SparkplugNet.
|
||||
**Tech Stack:** MQTTnet v5 (MIT, .NET Foundation, ships `net10.0`), vendored Eclipse Tahu `sparkplug_b.proto` + `Google.Protobuf` (already pinned 3.34.1) + `Grpc.Tools` (already pinned 2.76.0, build-time), bespoke browser.
|
||||
**Source design:** docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md
|
||||
|
||||
**Phases:** P1 (plain MQTT) = Tasks 0–14 (a complete shippable milestone); P2 (Sparkplug B ingest) = Tasks 15–26 (built on the P1 skeleton). Write-through (NCMD/DCMD/plain-publish) is deferred — see "Deferred / out of scope".
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting rules (apply to every task — program design §3.1)
|
||||
|
||||
- **Enum-serialization trap (systemic bug):** every JSON seam that (de)serializes `MqttDriverOptions` or a tag config — factory, probe, browser, **and** the AdminUI driver-config page + probe DTO — MUST share a `JsonSerializerOptions` carrying `new JsonStringEnumConverter()` + `PropertyNameCaseInsensitive=true` + `UnmappedMemberHandling=Skip`. `MqttMode`, `MqttPayloadFormat`, `protocolVersion` are enums; serialize them as **names**. Mirror `OpcUaClientDriverFactoryExtensions`.
|
||||
- **Per-op deadline (R2-01 frozen-peer lesson):** every network call (connect / subscribe / probe / rebirth-publish) has a bounded linked-CTS deadline. No unbounded waits on a dead broker.
|
||||
- **Ctor is connection-free:** `MqttDriver`/`MqttDriverBrowser` constructors touch no network; all connects happen in `InitializeAsync`/`OpenAsync` (the universal-browser `CanBrowse` throwaway-instance pattern depends on this).
|
||||
- **Secrets from env:** broker `password` is blank in committed JSON, supplied via env (mirror `ServerHistorian__ApiKey`). Never commit or log creds.
|
||||
- **Broker security — never ship anonymous/public-broker defaults:** default `useTls=true`, real auth. `allowUntrustedServerCertificate` + `caCertificatePath` mirror the ServerHistorian TLS knobs (dev/on-prem escape hatch, off by default). Fixtures run auth+TLS.
|
||||
- **DriverType string is `"Mqtt"`** everywhere (driver page, probe, factory, editor map, validator, browser). One constant `DriverTypeNames.Mqtt`; grep to enforce (heed the `ModbusTcp`/`Modbus` mismatch lesson).
|
||||
- **Live-verify discipline:** Razor binding + deploy-inertness bugs pass unit tests. Every picker/editor/deploy path gets a docker-dev `/run` live-verify (Tasks 14 + 26).
|
||||
- **New-project csproj:** `net10.0`, `Nullable` + `ImplicitUsings` enabled, `TreatWarningsAsErrors=true` opted in per-csproj (not global).
|
||||
|
||||
---
|
||||
|
||||
## Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (this is the gate — the design's #1 dependency risk; nothing else starts until it is green)
|
||||
**Files:**
|
||||
- Modify: `Directory.Packages.props` (add `<PackageVersion Include="MQTTnet" Version="5.x" />`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (temporary spike form — references `MQTTnet` only to prove the graph restores; the transport ref is removed in Task 1, `.Contracts` is transport-free)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project)
|
||||
|
||||
**Why first:** The design (§2.1, §10) makes this the top dependency risk — the repo uses central package management with `CentralPackageTransitivePinningEnabled` **deliberately OFF** (Roslyn 5.0.0/4.12.0 split, `Directory.Packages.props:103` comment + "Transitive pinning breaks Roslyn build" memory). We must prove **MQTTnet v5 restores and builds clean on net10 in this exact pinning configuration** before writing any driver code. Fresh-restore red-vs-local-green (the NU1903 memory) also means we validate a clean restore, not just an incremental one.
|
||||
|
||||
### Steps
|
||||
1. Confirm on NuGet the exact latest **MQTTnet v5** version carrying a `net10.0` TFM; pin that exact version in `Directory.Packages.props` (alphabetical position near `MessagePack`).
|
||||
2. Scaffold the `.Contracts` csproj (net10.0, nullable, implicit usings, TWAE) with a single `<PackageReference Include="MQTTnet" />` (no `Version` — central management supplies it). Add to `slnx`.
|
||||
3. Re-verify the two external-library record claims (design §2.1 checkbox): (a) MQTTnet v5 net10.0 TFM exists; (b) SparkplugNet still transitively pins MQTTnet 4.3.x with no net10.0 TFM. Record the confirmed versions in the tasks.json note. The hand-roll decision does not hinge on (b) — it is a confirm-the-record check.
|
||||
4. Prove a **clean** restore + build:
|
||||
```bash
|
||||
dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: no NU1605/NU1608 version-conflict, no NU1903 audit error
|
||||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: Build succeeded, 0 warnings (TWAE on)
|
||||
rm -rf ~/.nuget/packages/mqttnet && dotnet restore src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # fresh-restore proof
|
||||
```
|
||||
**Expected:** all three succeed with zero version-conflict / transitive-pin / audit errors. If restore fails on a 4/5 diamond or a missing net10 TFM, STOP — the hand-roll-Tahu decision (§2.1) is vindicated but the MQTTnet-5 pin itself is the blocker; resolve the exact version before proceeding.
|
||||
5. Commit:
|
||||
```bash
|
||||
git commit -am "feat(mqtt): validate MQTTnet-5/net10 restore under central pinning (spike, P1 gate)
|
||||
|
||||
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1 (P1): `.Contracts` — enums + `MqttDriverOptions` DTO
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 2 depends on this)
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (drop the temporary MQTTnet ref from Task 0 — `.Contracts` is transport-free; reference only `Core.Abstractions`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs` (`Plain | SparkplugB`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs` (`Json | Raw | Scalar`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs` (`V311 | V500`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs` (broker conn + mode + `sparkplug`/`plain` sub-objects, §5.1)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj` (net10, xUnit + Shouldly)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Write the failing test — options round-trip enum-as-name:
|
||||
```csharp
|
||||
public sealed class MqttDriverOptionsTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions J = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
|
||||
{
|
||||
const string json = """
|
||||
{ "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
|
||||
"sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
|
||||
""";
|
||||
var o = JsonSerializer.Deserialize<MqttDriverOptions>(json, J)!;
|
||||
o.Mode.ShouldBe(MqttMode.SparkplugB);
|
||||
o.UseTls.ShouldBeTrue();
|
||||
o.Sparkplug!.GroupId.ShouldBe("Plant1");
|
||||
o.Plain.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WritesEnumsAsNames_NotOrdinals()
|
||||
{
|
||||
var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
|
||||
s.ShouldContain("\"Plain\"");
|
||||
s.ShouldNotContain("\"mode\":0");
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL (types don't exist):
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests # RED
|
||||
```
|
||||
3. Implement the enums + `MqttDriverOptions` (with nested `MqttSparkplugOptions` / `MqttPlainOptions`) matching §5.1 defaults (`useTls=true`, `connectTimeoutSeconds=15`, `reconnectMin/MaxBackoffSeconds=1/30`). Wire the test csproj into `slnx`.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): Contracts options DTO + enums (name-serialized)"` (+ session trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 2 (P1): `.Contracts` — `MqttTagDefinition` + `MqttEquipmentTagParser` (plain)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 6 depends on the resolver)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttTagDefinition.cs` (parsed per-tag descriptor — carries plain OR sparkplug variant fields; plain fields populated in P1)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttEquipmentTagParser.cs` (`TryParse(reference, out def)` + `Inspect(reference)`, mirrors `ModbusEquipmentTagParser` / `ModbusTagDefinitionFactory`)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttEquipmentTagParserTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — parse a plain TagConfig blob, reject a typo'd enum (strict), and reject a wildcard topic:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void TryParse_PlainJsonBlob_PopulatesTopicAndPath()
|
||||
{
|
||||
const string r = """{"topic":"factory/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double","qos":1}""";
|
||||
MqttEquipmentTagParser.TryParse(r, out var def).ShouldBeTrue();
|
||||
def!.Topic.ShouldBe("factory/oven/temp");
|
||||
def.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
|
||||
def.Name.ShouldBe(r); // the def Name == reference string (forward-router key)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_TypoedPayloadFormat_RejectsStrict()
|
||||
=> MqttEquipmentTagParser.TryParse(
|
||||
"""{"topic":"a/b","payloadFormat":"Jason","dataType":"Double"}""", out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Inspect_WildcardTopic_ReturnsWarning()
|
||||
=> MqttEquipmentTagParser.Inspect("""{"topic":"a/+/c","payloadFormat":"Raw"}""").ShouldNotBeEmpty();
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement: a leading `{` marks a TagConfig blob; use `TagConfigJson.TryReadEnumStrict` for `payloadFormat`/`dataType` (typo → `false` → `BadNodeIdUnknown` upstream). `def.Name = reference`. `Inspect` returns a deploy-time warning list for present-but-invalid enums / unparseable blobs / wildcard tag-topics. Leave Sparkplug-descriptor parsing as a stub the P2 tasks fill (`groupId`/`edgeNodeId`/`deviceId`/`metricName`).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): plain tag parser + strict-enum descriptor"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 3 (P1): `.Driver` project + `MqttConnection` connect/TLS/auth (bounded)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 4 extends `MqttConnection`)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj` (net10, refs `.Contracts` + `Core.Abstractions` + `Core` + `MQTTnet`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (MQTTnet-5 client wrapper: build options from `MqttDriverOptions` incl. TLS + CA-pin + credentials; `ConnectAsync(ct)` under `connectTimeoutSeconds` linked-CTS)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — bounded connect against a dead endpoint fails fast (no hang), and TLS-option assembly honours the knobs. Use a closed loopback port for the deadline test:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ConnectAsync_DeadBroker_FailsWithinDeadline_NoHang()
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 2 };
|
||||
var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
||||
var sw = Stopwatch.StartNew();
|
||||
await Should.ThrowAsync<Exception>(() => conn.ConnectAsync(CancellationToken.None));
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5)); // deadline honoured, not wedged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndValidatesChain()
|
||||
{
|
||||
var opts = new MqttDriverOptions { Host="h", Port=8883, UseTls=true,
|
||||
AllowUntrustedServerCertificate=false, CaCertificatePath="/tmp/ca.pem" };
|
||||
var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
|
||||
built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions.UseTls.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement `MqttConnection`: static `BuildClientOptions(opts, clientIdSuffix)` (host/port, protocol version, clientId + optional suffix, keep-alive, clean-session, credentials, TLS with `AllowUntrustedServerCertificate` → custom cert validator, `CaCertificatePath` → chain pin). `ConnectAsync(ct)` links `ct` with a `connectTimeoutSeconds` CTS. Ctor stores options only — connection-free.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 4 (P1): `MqttConnection` hand-rolled reconnect loop (backoff + resubscribe)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (reconnect loop: exponential backoff `min→max`; on `DisconnectedAsync` schedule reconnect; on reconnect fire a `Reconnected` callback so the driver re-subscribes; expose `State` = Connected/Reconnecting/Faulted + `LastMessageAgeUtc`)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttConnectionTests.cs`
|
||||
|
||||
**Why high-risk:** MQTTnet v5 dropped v4's `ManagedMqttClient` (§8) — there is no library-managed reconnect. Subscriptions do not survive a clean session; a reconnect that forgets to re-subscribe silently goes dark. Backoff that ignores its cap can hot-loop a dead broker.
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — backoff schedule is bounded + monotone-to-cap, and a `Reconnected` event drives a re-subscribe callback. Test the backoff calculator as a pure function (no live broker):
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData(0, 1)] [InlineData(1, 2)] [InlineData(2, 4)] [InlineData(10, 30)] // caps at max=30
|
||||
public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds)
|
||||
=> MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
|
||||
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
|
||||
[Fact]
|
||||
public async Task OnReconnect_InvokesResubscribeCallback()
|
||||
{
|
||||
var conn = new MqttConnection(new MqttDriverOptions(), "t", null);
|
||||
var fired = 0; conn.Reconnected += () => { fired++; return Task.CompletedTask; };
|
||||
await conn.RaiseReconnectedForTest(); // test seam
|
||||
fired.ShouldBe(1);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement: `NextBackoff(attempt, min, max)` pure; a reconnect worker that loops on disconnect with backoff, honours `cleanSession`/session-expiry, re-subscribes via the `Reconnected` callback (idempotent insurance even for persistent sessions), and (Sparkplug, P2) re-requests rebirth. `State` transitions Connected↔Reconnecting; Faulted only on unrecoverable config. Never block the MQTTnet dispatcher thread.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): hand-rolled reconnect loop with bounded backoff + resubscribe"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 5 (P1): `LastValueCache` + `IReadable`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 6
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs` (`FullReference → DataValueSnapshot`, thread-safe)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/LastValueCacheTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — unseen reference returns a per-ref `GoodNoData`/uncertain snapshot (never throws the batch); a seeded ref returns last value:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Read_UnseenRef_ReturnsPerRefNoData_DoesNotThrow()
|
||||
{
|
||||
var c = new LastValueCache();
|
||||
var snap = c.Read("factory/oven/temp#$.value");
|
||||
snap.StatusCode.ShouldBe(StatusCodes.GoodNoData); // or Uncertain — per IReadable contract, per-ref status
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ThenRead_ReturnsLastValue()
|
||||
{
|
||||
var c = new LastValueCache();
|
||||
c.Update("k", DataValueSnapshot.Good(42.0, DateTime.UtcNow));
|
||||
c.Read("k").Value.ShouldBe(42.0);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement `LastValueCache` (concurrent dict); `MqttDriver.ReadAsync` (Task 7) returns `references.Select(cache.Read)` — batch never throws, per-ref `StatusCode` carries "not yet observed".
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): last-value cache backing IReadable (per-ref no-data)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 6 (P1): `ISubscribable` — plain topic subscribe → `OnDataChange` + retained seed
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 5
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttSubscriptionManager.cs` (registers `FullReference → MqttTagDefinition` via the shared `EquipmentTagRefResolver<MqttTagDefinition>`; dedupes/coalesces topics; on message: match topic → authored tag(s), extract value at `jsonPath`/raw/scalar, raise `OnDataChange`; seed from retained message on subscribe)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttConnection.cs` (expose `SubscribeAsync(filters, ct)` under a bounded deadline + a message-received event)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttSubscriptionManagerTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — feed a synthetic received message through the manager (no live broker); assert routing + JSONPath extraction fire `OnDataChange` with the right `FullReference`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void OnMessage_MatchingTopic_RaisesDataChangeAtJsonPath()
|
||||
{
|
||||
var mgr = new MqttSubscriptionManager();
|
||||
var handle = mgr.Register(new[] { """{"topic":"f/oven/temp","payloadFormat":"Json","jsonPath":"$.value","dataType":"Double"}""" });
|
||||
string? gotRef = null; object? gotVal = null;
|
||||
mgr.OnDataChange += (_, e) => { gotRef = e.FullReference; gotVal = e.Snapshot.Value; };
|
||||
mgr.HandleMessage("f/oven/temp", """{"value":21.5}"""u8.ToArray(), retained: false);
|
||||
gotVal.ShouldBe(21.5);
|
||||
gotRef.ShouldContain("f/oven/temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnMessage_UnauthoredTopic_RaisesNothing() // chatty broker must not auto-provision
|
||||
{
|
||||
var mgr = new MqttSubscriptionManager();
|
||||
mgr.Register(Array.Empty<string>());
|
||||
var fired = false; mgr.OnDataChange += (_, _) => fired = true;
|
||||
mgr.HandleMessage("random/topic", "1"u8.ToArray(), retained: false);
|
||||
fired.ShouldBeFalse();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement using `EquipmentTagRefResolver<MqttTagDefinition>` (parse-once cache, as Modbus does). `HandleMessage` matches topic → tag(s), extracts (Json→JSONPath token→typed value; Raw→bytes-as-string; Scalar→parse), updates `LastValueCache`, raises `OnDataChange`. Retained-flagged messages seed initial value. `SubscribeAsync` returns an `ISubscriptionHandle` whose `DiagnosticId` names mode+filter; completes under a bounded SUBACK deadline (SUBACK failure → per-ref Bad, not hang).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): plain subscribe→OnDataChange with retained seed + ref resolver"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 7 (P1): `MqttDriver` shell — `IDriver` + authored-only `ITagDiscovery` + `IHostConnectivityProbe`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 8/9 wire it)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`IDriver, ITagDiscovery, ISubscribable, IReadable, IHostConnectivityProbe, IRediscoverable`; composes `MqttConnection` + `MqttSubscriptionManager` + `LastValueCache`)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — `DiscoverAsync` streams **only authored tags** into a capturing `IAddressSpaceBuilder`; plain-mode `RediscoverPolicy == Once`; `SupportsOnlineDiscovery == false`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_Plain_StreamsAuthoredTagsOnly_PolicyOnce()
|
||||
{
|
||||
var driver = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.Plain }, "d", null);
|
||||
driver.SetAuthoredTagsForTest(new[] { """{"topic":"f/t","payloadFormat":"Raw","dataType":"String"}""" });
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
await driver.DiscoverAsync(b, CancellationToken.None);
|
||||
b.Variables.Count.ShouldBe(1);
|
||||
driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement: `InitializeAsync` deserializes options (shared `JsonSerializerOptions`), builds + connects `MqttConnection`, subscribes the mode-appropriate filter. `ReinitializeAsync` applies deltas in place (never crash → Faulted). `ShutdownAsync` disconnects/disposes. `GetHealth` = Connected/Reconnecting/Faulted + last-message-age. `GetMemoryFootprint` = caches; `FlushOptionalCachesAsync` = no-op (birth/alias are correctness state — forbidden to flush; last-value backs `IReadable`). `DiscoverAsync` replays authored tags only; `RediscoverPolicy => Once` (plain). `IRediscoverable.OnRediscoveryNeeded` never fires in plain mode.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): MqttDriver shell — lifecycle + authored-only discovery (Once)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 8 (P1): `MqttDriverProbe` — CONNECT handshake
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs` (`IDriverProbe`, `DriverType => "Mqtt"`; parse config with the shared options, open a CONNECT under the timeout, green + latency on CONNACK-accepted, targeted error on refused/TLS/auth/timeout — mirrors `ModbusDriverProbe`)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — `DriverType` is `"Mqtt"`; a dead endpoint yields a failed (not thrown) probe result within the deadline:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
|
||||
{
|
||||
var r = await new MqttDriverProbe().ProbeAsync(
|
||||
"""{"host":"127.0.0.1","port":1,"useTls":false,"connectTimeoutSeconds":2}""", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Message.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement; CONNACK-accepted is the MQTT "device is answering" proof. Use the **shared** `JsonSerializerOptions` (enum-as-name).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): MqttDriverProbe CONNECT handshake"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 9 (P1): Factory + `DriverTypeNames.Mqtt` + Host registration
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverFactoryExtensions.cs` (`DriverTypeName = "Mqtt"`, shared `JsonSerializerOptions`, `Register(registry, loggerFactory)` — mirror `OpcUaClientDriverFactoryExtensions`)
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs` (add `public const string Mqtt = "Mqtt";` + append to the `All` list)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs` (add `Driver.Mqtt.MqttDriverFactoryExtensions.Register(registry, loggerFactory);` in `Register(...)` near line 146; add `using MqttProbe = Driver.Mqtt.MqttDriverProbe;` + `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MqttProbe>());` in `AddOtOpcUaDriverProbes` near line 124)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (ProjectReference the `.Driver` assembly)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverFactoryExtensionsTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — `Register` binds the `"Mqtt"` type, and the factory builds an `MqttDriver` from JSON with string enums:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Register_ThenCreate_BuildsMqttDriver()
|
||||
{
|
||||
var registry = new DriverFactoryRegistry();
|
||||
MqttDriverFactoryExtensions.Register(registry);
|
||||
var d = registry.Create("Mqtt", "d1", """{"host":"h","port":1883,"mode":"Plain"}""");
|
||||
d.ShouldBeOfType<MqttDriver>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverTypeName_MatchesConstant()
|
||||
=> MqttDriverFactoryExtensions.DriverTypeName.ShouldBe(DriverTypeNames.Mqtt);
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement + wire both host sites. Build the whole solution to confirm registration compiles:
|
||||
```bash
|
||||
dotnet build ZB.MOM.WW.OtOpcUa.slnx # expect: Build succeeded
|
||||
```
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 10 (P1): `.Browser` — bespoke `#`-observation browser (passive)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 8
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj` (net10, refs `.Contracts` + `Commons(.Browsing)` + `MQTTnet`)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (`IDriverBrowser`, `DriverType => "Mqtt"`; `OpenAsync` connects with a `{clientId}-browse-{guid8}` suffix under a clamped 5–30 s budget; subscribes `#`/`{topicPrefix}#`; returns `MqttBrowseSession`; **publishes nothing**)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (`IBrowseSession` over a thread-safe accumulating observed tree; `RootAsync`/`ExpandAsync` split topic segments on `/`, leaf = `Kind=Leaf`; `AttributesAsync` = synthetic attribute w/ inferred type + last payload snippet; `DisposeAsync` disconnects best-effort)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — feed observed topics into the session's accumulating tree (test seam, no live broker); assert `RootAsync`/`ExpandAsync` build the segment tree and **no publish** occurs on any browse call:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ExpandAsync_BuildsTopicSegmentTree_FromObservedTopics()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
s.ObserveTopicForTest("factory/line3/oven/temp");
|
||||
var root = await s.RootAsync(CancellationToken.None);
|
||||
root.ShouldContain(n => n.BrowseName == "factory");
|
||||
var lvl2 = await s.ExpandAsync("factory", CancellationToken.None);
|
||||
lvl2.ShouldContain(n => n.BrowseName == "line3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseCalls_PublishNothing() // browse is read-only
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.Plain);
|
||||
await s.RootAsync(CancellationToken.None);
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement the accumulating tree + passive `OpenAsync`. In P1 only the plain `#` path is live; leave a Sparkplug hook the P2 tasks fill (`Group→EdgeNode→Device→Metric` + `RequestRebirthAsync`).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): bespoke passive #-observation browser (plain)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 11 (P1): Register the bespoke browser (overrides universal fallback)
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 12
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, MqttDriverBrowser>();` alongside the existing two near line 75–76)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (ProjectReference `.Browser`)
|
||||
|
||||
### Steps
|
||||
1. Add the registration + project reference. Registering for `DriverType="Mqtt"` overrides the universal fallback (`BrowserSessionService` resolves bespoke-first).
|
||||
2. Build: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI` — expect success.
|
||||
3. Commit: `git commit -am "feat(mqtt): register MqttDriverBrowser (bespoke-first for Mqtt)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 12 (P1): Typed AdminUI editor + validator (plain shape)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 11
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (thin typed model over a preserved `JsonObject` key bag; `FromJson`/`ToJson`/`Validate`, preserves unknown keys incl. history keys; carries a `Mode` field selecting sub-shape — P1 implements Plain `Validate`, Sparkplug stub filled in Task 24)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (`[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor)`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (`[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate()`)
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (copy the Modbus editor template — mode-switch at top, per-mode field group, client-side `Validate()`)
|
||||
- Create: `tests/Server/.../MqttTagConfigModelTests.cs` (co-locate with the existing AdminUI test project — verify path with `find tests/Server -name "*TagConfigModel*Tests.cs"`)
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — round-trip preserves unknown/history keys; plain `Validate` requires concrete topic + jsonPath-when-Json; re-derives `FullName`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void FromJson_ToJson_PreservesUnknownKeys()
|
||||
{
|
||||
var m = MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Raw","dataType":"String","isHistorized":true}""");
|
||||
m.ToJson().ShouldContain("isHistorized");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PlainWildcardTopic_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/+/c","payloadFormat":"Raw","dataType":"String"}""")
|
||||
.Validate().ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Validate_JsonWithoutPath_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Double"}""")
|
||||
.Validate().ShouldNotBeEmpty();
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement the model (mirror `OpcUaClientTagConfigModel`); `ToJson` writes PascalCase `FullName` re-derived from descriptor (`{topic}#{jsonPath}` plain). Build the `.razor`. Register both map entries.
|
||||
4. Run — expect PASS + `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI`.
|
||||
5. Commit: `git commit -am "feat(mqtt): typed tag editor + validator (plain mode)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 13 (P1): Mosquitto + JSON-publisher fixture (TLS+auth) + env-gated live suite
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests.csproj`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (`eclipse-mosquitto` with **auth + TLS** on `:8883`, plain `:1883` for smoke only; a JSON-publisher container emitting `retain=true` JSON on a few topics — `mosquitto_pub` loop or `paho-mqtt` script; `project=lmxopcua` label applied host-side)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/mosquitto.conf` + password/cert material generator script (creds via env, never real secrets committed)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/PlainMqttLiveTests.cs` (category `LiveIntegration`, gated on `MQTT_FIXTURE_ENDPOINT` — skips clean when unset → macOS-offline-safe)
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
### Steps
|
||||
1. Write the compose with **auth + TLS** (never anonymous). Add the JSON publisher with `retain=true`.
|
||||
2. Write env-gated tests: connect/TLS/auth; plain subscribe + retained-read seed; unauthored-topic silence. `[SkippableFact]` reading `MQTT_FIXTURE_ENDPOINT`.
|
||||
3. Confirm offline skip:
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: all Skipped (no env var)
|
||||
```
|
||||
4. Deploy the fixture to the docker host and run live (design §9):
|
||||
```bash
|
||||
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt
|
||||
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
|
||||
```
|
||||
5. Commit: `git commit -am "test(mqtt): Mosquitto TLS+auth fixture + env-gated plain live suite"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 14 (P1): Live `/run` verify on docker-dev — **P1 MILESTONE COMPLETE**
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (add the MQTT fixture endpoint to the Docker Workflow endpoint list: `10.100.0.35:1883/8883`, `MQTT_FIXTURE_ENDPOINT`)
|
||||
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT P1 code-complete)
|
||||
|
||||
### Steps
|
||||
1. On docker-dev (`:9200`, login disabled — run it yourself, do not defer): author an `Mqtt` driver (Plain mode) + a tag via the `/uns` picker (bespoke `#`-observation browser) or the typed editor; deploy.
|
||||
2. Confirm via Client.CLI the node carries live broker values:
|
||||
```bash
|
||||
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"
|
||||
```
|
||||
3. Confirm the typed editor renders (Razor binding bugs pass unit tests — live-verify is mandatory). Update CLAUDE.md + tracking doc.
|
||||
4. Commit: `git commit -am "docs(mqtt): P1 plain-MQTT milestone live-verified; endpoint recorded"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
# ── P2: Sparkplug B ingest (builds on the P1 skeleton) ──
|
||||
|
||||
## Task 15 (P2): Vendor Tahu proto + `Grpc.Tools` codegen
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (P2 root)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto` (vendored Eclipse Tahu `sparkplug_b.proto`, pinned to a known Tahu commit with a provenance comment header)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj` (add `Google.Protobuf` + build-time `Grpc.Tools` (`PrivateAssets=all`); `<Protobuf Include="Protos/sparkplug_b.proto" GrpcServices="None" />` — message-only codegen, this repo's first in-repo protoc step)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugProtoCodegenTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — the generated `Payload`/`Metric` types exist and round-trip a hand-built payload:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void GeneratedPayload_RoundTrips()
|
||||
{
|
||||
var p = new Org.Eclipse.Tahu.Protobuf.Payload { Seq = 3 };
|
||||
p.Metrics.Add(new Org.Eclipse.Tahu.Protobuf.Payload.Types.Metric { Name = "Temperature", Alias = 5 });
|
||||
var back = Org.Eclipse.Tahu.Protobuf.Payload.Parser.ParseFrom(p.ToByteArray());
|
||||
back.Seq.ShouldBe(3ul);
|
||||
back.Metrics[0].Alias.ShouldBe(5ul);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL (no generated types).
|
||||
3. Vendor the proto (provenance comment: Tahu commit hash + URL); wire `Grpc.Tools`. If in-repo protoc proves objectionable, fall back to checking in the generated C# with the same provenance comment (design §2.1). Build:
|
||||
```bash
|
||||
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts # expect: proto compiles, 0 warnings
|
||||
```
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 16 (P2): `SparkplugCodec` decode + golden payloads
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 17
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugCodec.cs` (decode `Payload`/`Metric` from wire bytes → a driver-side struct; encode NCMD deferred to `RebirthRequester` Task 20 / write-through P3)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugCodecTests.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/Golden/nbirth.bin` + `ndata.bin` (golden byte vectors, generated once from a hand-built payload and committed)
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — decode a golden NBIRTH: seq + metric name/alias/datatype/value survive:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Decode_GoldenNbirth_ExtractsMetrics()
|
||||
{
|
||||
var payload = SparkplugCodec.Decode(File.ReadAllBytes("Golden/nbirth.bin"));
|
||||
payload.Seq.ShouldBe((byte)0);
|
||||
payload.Metrics.ShouldContain(m => m.Name == "Temperature" && m.Alias == 5 && m.DataType == SparkplugDataType.Float);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement `SparkplugCodec.Decode`; generate the golden vectors in a one-off `[Fact(Skip="generator")]` or a small helper, commit the `.bin` files.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): SparkplugCodec decode + golden payload vectors"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 17 (P2): `SparkplugTopic` + `SparkplugDataType.ToDriverDataType`
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugTopic.cs` (parse/format `spBv1.0/{group}/{type}/{node}[/{device}]`; `type` ∈ NBIRTH/DBIRTH/NDATA/DDATA/NDEATH/DDEATH/NCMD/DCMD/STATE)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/SparkplugDataType.cs` (enum + `ToDriverDataType()` per the §3.5 map: Int8→Int16, UInt8→UInt16, DataSet/Template unsupported, `*Array`→element+IsArray)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugTopicTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — topic parse extracts group/type/node/device; datatype map widens Int8→Int16 and marks DataSet unsupported:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Parse_DeviceData_ExtractsAllSegments()
|
||||
{
|
||||
var t = SparkplugTopic.Parse("spBv1.0/Plant1/DDATA/EdgeA/Filler1");
|
||||
t.GroupId.ShouldBe("Plant1"); t.Type.ShouldBe(SparkplugMessageType.DDATA);
|
||||
t.EdgeNodeId.ShouldBe("EdgeA"); t.DeviceId.ShouldBe("Filler1");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SparkplugDataType.Int8, DriverDataType.Int16)]
|
||||
[InlineData(SparkplugDataType.UInt8, DriverDataType.UInt16)]
|
||||
[InlineData(SparkplugDataType.Float, DriverDataType.Float32)]
|
||||
public void ToDriverDataType_MapsAndWidens(SparkplugDataType s, DriverDataType d)
|
||||
=> s.ToDriverDataType().ShouldBe(d);
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug topic parse + datatype map"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 18 (P2): `BirthCache` + `AliasTable` — bind-by-name, rebuild-per-birth
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 19
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/AliasTable.cs` (per `(edgeNode,device)` alias→(name,datatype); **rebuilt wholesale each birth, never merged**)
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/BirthCache.cs` (NBIRTH/DBIRTH metric catalog: name/alias/datatype/last value)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/AliasBindingTests.cs`
|
||||
|
||||
**Why high-risk:** §3.6 invariants #1–#2 — binding data by alias silently mis-routes when an alias is reused for a different metric across a rebirth. Bind by **stable metric NAME**; the alias is a per-birth cache only.
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — the two load-bearing invariants:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Rebirth_ReusesAliasForDifferentMetric_ResolvesByName_NotAlias()
|
||||
{
|
||||
var t = new AliasTable();
|
||||
t.RebuildFromBirth(new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float) });
|
||||
t.Resolve(alias: 5).Name.ShouldBe("Temperature");
|
||||
// rebirth: alias 5 now means a DIFFERENT metric
|
||||
t.RebuildFromBirth(new[] { (name:"Pressure", alias:5ul, dt:SparkplugDataType.Float) });
|
||||
t.Resolve(alias: 5).Name.ShouldBe("Pressure"); // wholesale replace, not merge
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebuildFromBirth_DoesNotMergeStaleAliases()
|
||||
{
|
||||
var t = new AliasTable();
|
||||
t.RebuildFromBirth(new[] { (name:"A", alias:1ul, dt:SparkplugDataType.Int32) });
|
||||
t.RebuildFromBirth(new[] { (name:"B", alias:2ul, dt:SparkplugDataType.Int32) });
|
||||
t.TryResolve(alias: 1, out _).ShouldBeFalse(); // alias 1 gone after rebirth
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement — `RebuildFromBirth` replaces the whole map. `BirthCache` keeps the metric catalog + last value keyed by name.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): AliasTable/BirthCache — bind-by-name, rebuild-per-birth"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 19 (P2): `SequenceTracker` — seq-gap + bdSeq death-tie
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 18
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SequenceTracker.cs` (per edge-node `seq` 0–255 wrap gap detection; `bdSeq` ties NDEATH↔NBIRTH)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SequenceTrackerTests.cs`
|
||||
|
||||
**Why high-risk:** §3.6 invariant #3 — a wrong wrap boundary (255→0 is NOT a gap) either misses real gaps (stale data) or false-positives every wrap (rebirth-storm).
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — contiguous ok, wrap ok, gap detected:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Sequence_WrapAt255IsContiguous_NotAGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(254).ShouldBeTrue(); s.Accept(255).ShouldBeTrue(); s.Accept(0).ShouldBeTrue(); // wrap
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sequence_SkippedValue_IsGap()
|
||||
{
|
||||
var s = new SequenceTracker();
|
||||
s.Accept(10).ShouldBeTrue();
|
||||
s.Accept(12).ShouldBeFalse(); // gap → caller requests rebirth
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement (next-expected = `(last+1) & 0xFF`); `bdSeq` compare helper.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): SequenceTracker seq-gap + bdSeq death-tie"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 20 (P2): `RebirthRequester` — NCMD encode
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/RebirthRequester.cs` (encode an NCMD writing `Node Control/Rebirth = true` to `spBv1.0/{group}/NCMD/{node}`; publish under a bounded deadline)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/RebirthRequesterTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing test — encoded NCMD carries the `Node Control/Rebirth`=true metric and targets the right topic:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void BuildRebirthNcmd_EncodesControlMetric_AndTopic()
|
||||
{
|
||||
var (topic, bytes) = RebirthRequester.Build("Plant1", "EdgeA");
|
||||
topic.ShouldBe("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
var p = SparkplugCodec.Decode(bytes);
|
||||
p.Metrics.ShouldContain(m => m.Name == "Node Control/Rebirth" && Equals(m.Value, true));
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement (encode path via the generated proto).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): RebirthRequester NCMD encode"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 21 (P2): Sparkplug ingest state machine — the §3.6 matrix
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (integrates Tasks 16–20 into the driver)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/Sparkplug/SparkplugIngestor.cs` (routes decoded messages: N/DBIRTH → rebuild `AliasTable` + `BirthCache`; N/DDATA → resolve alias→name → map to authored `FullReference` **by (group,node,device,metricName)** → `OnDataChange`; N/DDEATH → emit STALE/Bad snapshots; seq-gap / unknown-alias / data-before-birth → `RebirthRequester` gated on `requestRebirthOnGap`; STATE/primary-host handling; late-join rebirth on connect)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (Sparkplug-mode subscribe `spBv1.0/{group}/#` (+ STATE) → `SparkplugIngestor`; reconnect → re-subscribe + request rebirth)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugIngestorTests.cs`
|
||||
|
||||
**Why high-risk:** this is the §3.6 correctness core — the #1 risk in the design. Test the full matrix: rebirth, missed-birth, seq-gap, alias-reuse-across-rebirth, death→stale→rebirth.
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — the §3.6 matrix, driven through the ingestor with synthetic decoded messages (no live broker):
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Birth_Then_Data_ResolvesByAlias_RaisesOnDataChangeByName()
|
||||
{
|
||||
var ing = new SparkplugIngestor(...); // authored tag: Plant1/EdgeA/Filler1:Temperature
|
||||
string? firedRef = null; ing.OnDataChange += (_, e) => firedRef = e.FullReference;
|
||||
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
|
||||
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:1, new[] { (alias:5ul, val:(object)21.5f) });
|
||||
firedRef.ShouldContain("Filler1:Temperature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataBeforeBirth_RequestsRebirth()
|
||||
{
|
||||
var ncmds = new List<string>();
|
||||
var ing = new SparkplugIngestor(..., publishNcmd: (t,_) => ncmds.Add(t));
|
||||
ing.HandleDdata("Plant1","EdgeA","Filler1", seq:0, new[] { (alias:9ul, val:(object)1f) });
|
||||
ncmds.ShouldContain("spBv1.0/Plant1/NCMD/EdgeA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ddeath_EmitsStaleForDeviceMetrics_NextBirthRestoresGood()
|
||||
{
|
||||
var ing = new SparkplugIngestor(...);
|
||||
var quals = new List<StatusCode>(); ing.OnDataChange += (_, e) => quals.Add(e.Snapshot.StatusCode);
|
||||
ing.HandleDbirth("Plant1","EdgeA","Filler1", new[] { (name:"Temperature", alias:5ul, dt:SparkplugDataType.Float, val:(object)0f) });
|
||||
ing.HandleDdeath("Plant1","EdgeA","Filler1");
|
||||
quals.ShouldContain(q => StatusCode.IsBad(q)); // STALE/Bad on death
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement the ingestor holding the §3.6 invariants. Route via `EquipmentTagRefResolver` keyed by `(group,node,device,metricName)`.
|
||||
4. Run — expect PASS (all matrix cases).
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug ingest state machine (birth/alias/seq/rebirth/death→stale)"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 22 (P2): `ITagDiscovery` `UntilStable` + `IRediscoverable`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs` (`RediscoverPolicy => UntilStable` in Sparkplug mode — authored tags' datatypes fill in as births arrive; `DiscoverAsync` re-streams authored tags with resolved datatypes from `BirthCache`; fire `OnRediscoveryNeeded` on a **new DBIRTH** or a rebirth with a changed metric set, `ScopeHint` = edge-node/device folder path)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverDiscoveryTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — Sparkplug policy is `UntilStable`; a new DBIRTH fires `OnRediscoveryNeeded`; a tag authored before its birth picks up the birth datatype:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void SparkplugMode_RediscoverPolicy_IsUntilStable()
|
||||
=> new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null)
|
||||
.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
|
||||
|
||||
[Fact]
|
||||
public async Task NewDbirth_FiresOnRediscoveryNeeded()
|
||||
{
|
||||
var d = new MqttDriver(new MqttDriverOptions { Mode = MqttMode.SparkplugB }, "d", null);
|
||||
var fired = false; ((IRediscoverable)d).OnRediscoveryNeeded += (_, _) => fired = true;
|
||||
d.SimulateNewDbirthForTest("Plant1","EdgeA","Filler2");
|
||||
fired.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug UntilStable discovery + rediscover-on-DBIRTH"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 23 (P2): Sparkplug browser tree + `AttributesAsync` + `RequestRebirthAsync`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 24
|
||||
**Files:**
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs` (Sparkplug tree Group→EdgeNode→Device→Metric from observed births; `AttributesAsync` = self-describing metric `AttributeInfo{Name, DriverDataType from birth, IsArray, SecurityClass}`; `RequestRebirthAsync(scope)` — the **only** publishing session member, scoped to selected group/edge-node, via the `RebirthRequester` path)
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs` (Sparkplug discovery filter `spBv1.0/{groupId}/#`; `OpenAsync` still publishes nothing)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs` (dispatch the MQTT-specific `RequestRebirthAsync` for MQTT sessions, gated by the same `DriverOperator` policy that gates the picker; Info-log the target scope)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — birth-driven tree fills; `OpenAsync`/`RootAsync`/`ExpandAsync` publish **nothing**; `RequestRebirthAsync` publishes exactly one scoped NCMD:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","Temperature", SparkplugDataType.Float);
|
||||
var groups = await s.RootAsync(default);
|
||||
groups.ShouldContain(n => n.BrowseName == "Plant1");
|
||||
s.PublishCountForTest.ShouldBe(0); // passive
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
|
||||
{
|
||||
var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
s.ObserveBirthForTest("Plant1","EdgeA","Filler1","T", SparkplugDataType.Float);
|
||||
await s.RequestRebirthAsync(scope: "Plant1/EdgeA");
|
||||
s.PublishCountForTest.ShouldBe(1);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement. `RequestRebirthAsync` is never fired by `OpenAsync`/`RootAsync`/`ExpandAsync` — only on explicit operator click.
|
||||
4. Run — expect PASS + `dotnet build` the AdminUI.
|
||||
5. Commit: `git commit -am "feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 24 (P2): AdminUI editor Sparkplug mode shape + validator
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 23
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MqttTagConfigModel.cs` (Sparkplug `Validate`: `groupId`/`edgeNodeId`/`metricName` required, `deviceId` optional, `dataType` a known Sparkplug type; `ToJson` re-derives `FullName` = `{group}/{node}[/{device}]:{metric}`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MqttTagConfigEditor.razor` (Sparkplug field group under the mode switch)
|
||||
- Modify: `tests/Server/.../MqttTagConfigModelTests.cs`
|
||||
|
||||
### Steps (TDD)
|
||||
1. Failing tests — Sparkplug validation + FullName derivation:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Validate_SparkplugMissingMetricName_Fails()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA"}""").Validate().ShouldNotBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void ToJson_Sparkplug_DerivesFullName()
|
||||
=> MqttTagConfigModel.FromJson("""{"groupId":"Plant1","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Temperature","dataType":"Float"}""")
|
||||
.ToJson().ShouldContain("Plant1/EdgeA/Filler1:Temperature");
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. Implement.
|
||||
4. Run — expect PASS + AdminUI build.
|
||||
5. Commit: `git commit -am "feat(mqtt): typed tag editor Sparkplug mode + validation"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 25 (P2): C# edge-node simulator fixture + §3.6 live matrix
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugSimulator/` (a project-owned C# edge-node simulator using the **same** MQTTnet-5 + Tahu-proto path the driver uses — publishes NBIRTH/DBIRTH → periodic N/DDATA; honours a rebirth NCMD; injects seq-gap / alias-reuse / N/DDEATH on demand)
|
||||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/Docker/docker-compose.yml` (add the simulator container against the same TLS+auth Mosquitto)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests/SparkplugLiveTests.cs` (category `LiveIntegration`, env-gated: birth→data→alias-resolve→OnDataChange; rebirth recovery; death→STALE; browser passive window asserts **no NCMD published by open/browse**; explicit `RequestRebirthAsync` node-vs-group enumeration)
|
||||
|
||||
### Steps
|
||||
1. Build the simulator (encode via the same generated proto — validates encode/decode symmetry).
|
||||
2. Write env-gated live tests covering the §3.6 matrix + browser passivity.
|
||||
3. Offline skip proof:
|
||||
```bash
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests # expect: Skipped without MQTT_FIXTURE_ENDPOINT
|
||||
```
|
||||
4. Deploy + run live:
|
||||
```bash
|
||||
lmxopcua-fix sync mqtt && lmxopcua-fix up mqtt sparkplug
|
||||
export MQTT_FIXTURE_ENDPOINT=10.100.0.35:8883
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.IntegrationTests --filter "Category=LiveIntegration" # expect: PASS
|
||||
```
|
||||
5. Commit: `git commit -am "test(mqtt): C# Sparkplug edge-node simulator + §3.6 live matrix"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Task 26 (P2): Live `/run` verify Sparkplug — **P2 COMPLETE**
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-07-15-driver-expansion-tracking.md` (mark MQTT/Sparkplug P1+P2 code-complete + live-verified)
|
||||
- Modify: `CLAUDE.md` (if a driver-fleet or endpoint fact changed; propagate to `../scadaproj/CLAUDE.md` OtOpcUa entry per the cross-repo rule)
|
||||
|
||||
### Steps
|
||||
1. On docker-dev (`:9200`): author an `Mqtt` driver (SparkplugB mode) against the simulator; browse the birth-driven picker, click **Request rebirth**, pick a metric, deploy.
|
||||
2. Confirm live values + rediscover-on-DBIRTH + death→STALE via Client.CLI reads/subscribe.
|
||||
3. Confirm the typed editor renders both modes (live-verify — Razor bugs pass unit tests).
|
||||
4. Update tracking + (if needed) CLAUDE.md/scadaproj index.
|
||||
5. Commit: `git commit -am "docs(mqtt): Sparkplug P2 live-verified; MQTT driver complete"` (+ trailer).
|
||||
|
||||
---
|
||||
|
||||
## Deferred / out of scope
|
||||
|
||||
- **Write-through (P3 in the design):** `IWritable` — Sparkplug NCMD/DCMD + plain publish, optimistic-Good + self-correct on echo (mirrors Galaxy fire-and-forget), `WriteIdempotent` default-off for non-idempotent Sparkplug commands, write-topic config. See design §3.7 + §10 (P3 row). `MqttDriver` ships **read/subscribe/discover only** in P1+P2 — its absence of `IWritable` means nodes materialize read-only.
|
||||
- **`DataSet`/`Template` Sparkplug metrics** — unsupported v1 (skip + warn, or raw-JSON as String). §3.5.
|
||||
- **`Bytes`/`File` metrics** beyond a base64/raw-String fallback. §3.5.
|
||||
- **EMQX broker** — Mosquitto is the default fixture; EMQX is the documented alternative when a dashboard/built-in Sparkplug tooling helps (§9), not built here.
|
||||
|
||||
## Notes on ordering / parallelism
|
||||
|
||||
- **P2 is blocked on the P1 milestone (Task 14).** Every P2 task's `blockedBy` chain roots at Task 14 so plain MQTT ships and is live-verified before Sparkplug work begins — and MQTTnet-5/net10 is proven (Task 0) before any proto/Sparkplug effort.
|
||||
- Genuine parallel pairs: 5‖6, 8‖10, 11‖12, 16‖17, 18‖19, 23‖24.
|
||||
- DRY: reuse `EquipmentTagRefResolver<MqttTagDefinition>` (as Modbus/OpcUaClient do), the shared `JsonSerializerOptions`, and one `SparkplugCodec` for both decode (ingest) and encode (rebirth NCMD). YAGNI: no write-through plumbing, no DataSet/Template, no EMQX until a need lands.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md",
|
||||
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []},
|
||||
{"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]},
|
||||
{"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]},
|
||||
{"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]},
|
||||
{"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]},
|
||||
{"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]},
|
||||
{"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]},
|
||||
{"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]},
|
||||
{"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]},
|
||||
{"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]},
|
||||
{"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]},
|
||||
{"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]},
|
||||
{"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]},
|
||||
{"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]},
|
||||
{"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]},
|
||||
{"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]},
|
||||
{"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mtconnect-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]},
|
||||
{"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]},
|
||||
{"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]},
|
||||
{"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]},
|
||||
{"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]},
|
||||
{"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]},
|
||||
{"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]},
|
||||
{"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]},
|
||||
{"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]},
|
||||
{"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]},
|
||||
{"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
# SQL Poll Driver Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Ship a read-only `Sql` Equipment-kind driver that polls SQL Server tables/views on an interval and publishes selected columns/rows as OPC UA variable nodes, authored through the standard equipment Tags flow with a bespoke schema browser.
|
||||
|
||||
**Architecture:** Three new projects mirroring the Modbus split — `Driver.Sql.Contracts` (options/tag DTOs + parser, zero transport deps), `Driver.Sql` (the `IDriver`/`ITagDiscovery`/`IReadable`/`ISubscribable`/`IHostConnectivityProbe` runtime + `ISqlDialect` seam + `SqlServerDialect`, owning `Microsoft.Data.SqlClient`), and `Driver.Sql.Browser` (schema-walk `IBrowseSession`). The `PollGroupEngine`, `EquipmentTagRefResolver<TDef>`, health state machine, and factory shape are all reused from Core; the driver batches tags by query-group (one round-trip per group per poll) and slices result sets back to per-tag snapshots. Every value binds as a `DbParameter`; identifiers are dialect-quoted from catalog-validated names only.
|
||||
|
||||
**Tech Stack:** `Microsoft.Data.SqlClient` (6.1.1, already in `Directory.Packages.props`) via `System.Data.Common` base types behind a `DbProviderFactory`; `ISqlDialect` + `SqlProvider` enum seam (only `SqlServer` implemented in v1); `Microsoft.Data.Sqlite` (10.0.7, test-only) for the offline unit fixture; xunit.v3 + Shouldly. **Zero new NuGet dependencies.**
|
||||
|
||||
**Source design:** docs/plans/2026-07-15-sql-poll-driver-design.md
|
||||
**Program design (shared contract):** docs/plans/2026-07-15-driver-expansion-program-design.md §3, §7
|
||||
|
||||
**Conventions for every task below:** all projects `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors` on product projects. Every commit message ends with the repo trailer `Claude-Session: <session-url>` (omitted from the `git commit` examples for brevity). Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` green before each commit. New test projects use `xunit.v3` + `Shouldly` (see `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/*.csproj`).
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Scaffold `Driver.Sql.Contracts` project + register in slnx
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (root of the tree)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add the project under `/src/Drivers/`)
|
||||
|
||||
**Steps:**
|
||||
1. Create the csproj — refs `Core.Abstractions` ONLY (no transport deps, so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`). `InternalsVisibleTo` the Contracts is not needed. `RootNamespace = ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts`.
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under the `/src/Drivers/` folder, beside the `Driver.Modbus.Contracts` line.
|
||||
3. Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS (empty project compiles).
|
||||
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Contracts project"`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `SqlProvider` + `SqlTagModel` enums + `SqlDriverConfigDto` (string-enum round-trip)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (blocks most downstream)
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlDriverConfigDto.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/*` is NOT created here — this task's test lives temporarily in the Contracts consumer; create the Tests project in Task 6. **Instead**, put this task's test in a throwaway `Contracts`-adjacent xunit project? No — to avoid a project just for one test, fold this task's serialization assertion into Task 9's factory enum-serialization test. **This task ships DTOs only; its guard is Task 9.**
|
||||
|
||||
> **Note:** Tasks 1–2 produce pure records/enums with no I/O. Their behavioural guard (string-enum round-trip, strict parse) is the Task 9 factory test + Task 2's parser test (Task 2 creates its own micro-test via the Tests project created in Task 6). To keep TDD honest without a premature test project, **reorder locally**: implement Task 1 DTOs, then Task 6 (Tests project) can be pulled forward if the implementer prefers. The `blockedBy` graph permits this. The concrete failing-test-first discipline begins at Task 2.
|
||||
|
||||
**Steps:**
|
||||
1. `SqlProvider.cs`:
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
|
||||
public enum SqlProvider { SqlServer, Postgres, MySql, Odbc, Oracle }
|
||||
|
||||
/// <summary>Tag→value mapping model. v1 ships KeyValue + WideRow; Query is deferred (design §5.4 / P3).</summary>
|
||||
public enum SqlTagModel { KeyValue, WideRow, Query }
|
||||
```
|
||||
2. `SqlDriverConfigDto.cs` — the driver-config blob shape (§5.1). Enum-typed fields (`Provider`) so the string converter round-trips names; timeouts as `TimeSpan?`; `ConnectionStringRef` (NOT the connection string). Fields: `Provider`, `ConnectionStringRef`, `DefaultPollInterval`, `OperationTimeout`, `CommandTimeout`, `MaxConcurrentGroups`, `NullIsBad`, `AllowWrites`, `Probe` (`SqlProbeDto{Enabled, Interval}`). `[JsonStringEnumConverter]` is applied at the factory (Task 9), not on the DTO.
|
||||
3. Build — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): SqlProvider/SqlTagModel enums + SqlDriverConfigDto"`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SqlTagDefinition` + `SqlEquipmentTagParser.TryParse` (strict enum reads, malicious-input safe)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlTagDefinition.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlEquipmentTagParser.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlEquipmentTagParserTests.cs` (Tests project scaffolded in Task 6 — **pull the csproj creation forward from Task 6 for this task**, or defer this task's test until Task 6 and mark Task 2 blockedBy nothing but verified-at-6. Recommended: create the Tests project now as part of this task's setup.)
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — parse a KeyValue blob and a malicious `keyValue`; assert the value is captured verbatim (parser does NOT sanitize — binding is the reader's job) and a typo'd `model` enum rejects:
|
||||
```csharp
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
public class SqlEquipmentTagParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParse_KeyValue_readsFields_andKeepsMaliciousValueVerbatim()
|
||||
{
|
||||
var json = """
|
||||
{"driver":"Sql","model":"KeyValue","table":"dbo.TagValues","keyColumn":"tag_name",
|
||||
"keyValue":"'; DROP TABLE x --","valueColumn":"num_value","timestampColumn":"sample_ts","type":"Float64"}
|
||||
""";
|
||||
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/Speed", out var def).ShouldBeTrue();
|
||||
def.Model.ShouldBe(SqlTagModel.KeyValue);
|
||||
def.Table.ShouldBe("dbo.TagValues");
|
||||
def.KeyValue.ShouldBe("'; DROP TABLE x --"); // captured as-is; the reader BINDS it, never concatenates
|
||||
def.DeclaredType.ShouldBe(DriverDataType.Float64);
|
||||
def.Name.ShouldBe("Plant/Sql/dev1/Speed"); // Name == RawPath (forward-router key)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_invalidModelEnum_rejectsTag()
|
||||
=> SqlEquipmentTagParser.TryParse(
|
||||
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
|
||||
"raw", out _).ShouldBeFalse();
|
||||
}
|
||||
```
|
||||
2. Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests` — expect FAIL (types don't exist).
|
||||
3. **Minimal impl** — `SqlTagDefinition` record (`Name` = RawPath, `Model`, `Table`, `KeyColumn`, `KeyValue`, `ValueColumn`, `TimestampColumn`, `ColumnName`, `RowSelectorColumn`, `RowSelectorValue`, `RowSelectorTopByTimestamp`, `DeclaredType` = `DriverDataType?`). `SqlEquipmentTagParser.TryParse(string reference, string rawPath, out SqlTagDefinition def)` — mirror `ModbusTagDefinitionFactory.FromTagConfig`: recognize by leading `{`, read the `model` discriminator via `TagConfigJson.TryReadEnumStrict` (a present-but-invalid enum → `false` → the driver surfaces `BadNodeIdUnknown`, per R2-11), read model-specific fields, read the optional `type` override strictly, set `Name = rawPath`. Wrap in try/catch(JsonException/FormatException) → `false`. **No SQL is built here** — the parser only captures strings.
|
||||
4. Run test — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlTagDefinition + strict-enum equipment-tag parser"`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Scaffold `Driver.Sql` runtime project + register in slnx
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 2
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
**Steps:**
|
||||
1. Create the csproj per design §2.1 — refs `Core.Abstractions` + `Core` (for `DriverFactoryRegistry` in `ZB.MOM.WW.OtOpcUa.Core.Hosting`) + `Driver.Sql.Contracts`; packages `Microsoft.Data.SqlClient` + `Microsoft.Extensions.Logging.Abstractions`; `InternalsVisibleTo` the `.Tests`.
|
||||
2. Add to `ZB.MOM.WW.OtOpcUa.slnx` under `/src/Drivers/`.
|
||||
3. Build — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql runtime project"`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `ISqlDialect` seam + `SqlServerDialect` (QuoteIdentifier, catalog SQL, MapColumnType) — golden queries
|
||||
|
||||
**Classification:** high-risk (identifier quoting is the injection boundary)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 2
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlServerDialectTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — golden catalog SQL, `QuoteIdentifier` escape + reject, `MapColumnType` table:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void QuoteIdentifier_bracketsAndEscapesEmbeddedBrackets()
|
||||
{
|
||||
var d = new SqlServerDialect();
|
||||
d.QuoteIdentifier("Speed").ShouldBe("[Speed]");
|
||||
d.QuoteIdentifier("a]b").ShouldBe("[a]]b]"); // ] doubled per T-SQL rules
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuoteIdentifier_rejectsControlCharsAndNul()
|
||||
=> Should.Throw<ArgumentException>(() => new SqlServerDialect().QuoteIdentifier("x\0y"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("bit", DriverDataType.Boolean)]
|
||||
[InlineData("int", DriverDataType.Int32)]
|
||||
[InlineData("bigint", DriverDataType.Int64)]
|
||||
[InlineData("real", DriverDataType.Float32)]
|
||||
[InlineData("float", DriverDataType.Float64)]
|
||||
[InlineData("decimal", DriverDataType.Float64)]
|
||||
[InlineData("nvarchar", DriverDataType.String)]
|
||||
[InlineData("datetime2", DriverDataType.DateTime)]
|
||||
[InlineData("uniqueidentifier", DriverDataType.String)]
|
||||
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
|
||||
=> new SqlServerDialect().MapColumnType(sql).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void CatalogSql_isInformationSchemaAndParameterized()
|
||||
{
|
||||
var d = new SqlServerDialect();
|
||||
d.LivenessSql.ShouldBe("SELECT 1");
|
||||
d.ListTablesSql.ShouldContain("INFORMATION_SCHEMA.TABLES");
|
||||
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
|
||||
d.ListColumnsSql.ShouldContain("@table");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `ISqlDialect` per design §2.2 (`Provider`, `Factory` = `DbProviderFactory`, `QuoteIdentifier`, `LivenessSql`, `ListSchemasSql`, `ListTablesSql`, `ListColumnsSql`, `MapColumnType`). `SqlServerDialect`: `Factory => SqlClientFactory.Instance`; `QuoteIdentifier` doubles `]`, rejects embedded NUL/control chars via `ArgumentException`; catalog SQL uses `INFORMATION_SCHEMA` with `@schema`/`@table` markers; `MapColumnType` folds SQL type families to `DriverDataType` per design §3.7 (`decimal`/`numeric`/`money` → `Float64` with the documented precision caveat as an XML remark).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): ISqlDialect + SqlServerDialect with golden catalog SQL"`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `SqlQueryPlan` grouping + parameter binding (pure, no DB)
|
||||
|
||||
**Classification:** high-risk (the query-mapping core; GroupKey correctness governs correctness of every read)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlGroupPlannerTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — KeyValue tags on the same `(table,keyColumn,valueColumn,timestampColumn)` fold into ONE plan whose SQL is `... WHERE [tag_name] IN (@k0,@k1)` with two bound params; WideRow tags on the same `(table,rowSelector)` fold into one `SELECT <cols> ... WHERE [station_id]=@w`; different tables → different plans:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void KeyValueTags_sameSource_foldIntoOnePlan_withBoundInList()
|
||||
{
|
||||
var dialect = new SqlServerDialect();
|
||||
var tags = new[] {
|
||||
KvTag("A","dbo.TagValues","tag_name","Line1.Speed","num_value","sample_ts"),
|
||||
KvTag("B","dbo.TagValues","tag_name","Line1.Temp","num_value","sample_ts"),
|
||||
};
|
||||
var plans = SqlGroupPlanner.Plan(tags, dialect).ToList();
|
||||
plans.Count.ShouldBe(1);
|
||||
plans[0].SqlText.ShouldContain("IN (@k0, @k1)");
|
||||
plans[0].SqlText.ShouldContain("[tag_name]");
|
||||
plans[0].Members.Count.ShouldBe(2);
|
||||
plans[0].Parameters.ShouldBe(new object[] { "Line1.Speed", "Line1.Temp" }); // values are params, not text
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlQueryPlan { object GroupKey, string SqlText, IReadOnlyList<object?> Parameters, IReadOnlyList<SqlTagDefinition> Members, Func<DbDataReader, SqlTagDefinition, ...> }` (indexer supplied by the reader in Task 7; here just the shape + SQL/param builder). `SqlGroupPlanner.Plan(tags, dialect)`: KeyValue → GroupKey `(table,keyColumn,valueColumn,timestampColumn)`, emit `SELECT <keyColumn>,<valueColumn>[,<timestampColumn>] FROM <table> WHERE <keyColumn> IN (@k0..@kN)` with distinct-key params; WideRow → GroupKey `(table, rowSelector)`, emit `SELECT <distinct member columns>[,<selector cols>] FROM <table> WHERE <whereColumn>=@w` (or `ORDER BY <col> DESC` + dialect TOP-1 for `topByTimestamp`). Identifiers via `dialect.QuoteIdentifier`; `<table>` split on `.` and each part quoted. **Every value is a param; zero interpolation.**
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlGroupPlanner folds tags into one parameterized query per group"`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Scaffold `Driver.Sql.Tests` + `SqliteDialect` (test) + `SqlitePollFixture`
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10, Task 12
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests.csproj`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlitePollFixture.cs`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
> If Task 2 already created the csproj, this task adds the `SqliteDialect`/fixture and the slnx entry only.
|
||||
|
||||
**Steps:**
|
||||
1. csproj — `xunit.v3` + `Shouldly` + `Microsoft.NET.Test.Sdk` + `xunit.runner.visualstudio`; `PackageReference Microsoft.Data.Sqlite` (10.0.7, test-only — no product dep on SQLite); project-refs `Driver.Sql` + `Driver.Sql.Contracts`. Add to slnx under `/tests/Drivers/`.
|
||||
2. `SqliteDialect : ISqlDialect` — `Provider = SqlServer` shim is wrong; add a test-only `Provider` value is not needed — implement `ISqlDialect` directly with `Factory => SqliteFactory.Instance`, `QuoteIdentifier` doubling `"`, `LivenessSql = "SELECT 1"`, catalog SQL over `sqlite_schema` + `PRAGMA table_info(...)`, `MapColumnType` mapping declared affinity (design §9 caveat: SQLite is dynamically typed — keep seed columns explicitly typed). This dialect is also linked by the Browser.Tests (Task 13) via `<Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" />`.
|
||||
3. `SqlitePollFixture` — opens an in-memory (or temp-file) SQLite connection, seeds a KeyValue table `TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)` and a wide-row `LatestStatus(station_id INT, oven_temp REAL, sample_ts TEXT)` with a few rows. Exposes the open `SqliteConnection` + a `DbProviderFactory` shim so the driver-under-test can be injected with an already-open connection provider (see Task 8's ctor seam).
|
||||
4. Build + run (no tests yet) — expect PASS/empty.
|
||||
5. Commit: `git commit -m "test(sql): Sql.Tests project + SqliteDialect + poll fixture"`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: `SqlPollReader` core — one query per group, bounded deadline, slice back in input order
|
||||
|
||||
**Classification:** high-risk (connection/timeout/result-slicing core)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlPollReaderTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** (against `SqlitePollFixture`) — read two KeyValue refs + one absent key; assert N-in/N-out order, correct values/types, absent key → `BadNoData`, NULL cell → `Uncertain` (default `nullIsBad=false`), source timestamp from `timestampColumn`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync(); // seeds Line1.Speed=42.0, Line1.Temp=NULL
|
||||
var reader = new SqlPollReader(fx.Factory, fx.ConnectionString, new SqliteDialect(),
|
||||
commandTimeout: TimeSpan.FromSeconds(10), operationTimeout: TimeSpan.FromSeconds(15),
|
||||
maxConcurrentGroups: 4, nullIsBad: false, resolve: fx.Resolve);
|
||||
var refs = new[] { "Speed", "Missing", "Temp" };
|
||||
var snaps = await reader.ReadAsync(refs, CancellationToken.None);
|
||||
snaps.Count.ShouldBe(3);
|
||||
snaps[0].Value.ShouldBe(42.0);
|
||||
snaps[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // key deleted/absent
|
||||
snaps[2].Value.ShouldBeNull();
|
||||
StatusCode.IsUncertain(snaps[2].StatusCode).ShouldBeTrue(); // NULL cell, nullIsBad=false
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlPollReader.ReadAsync(refs, ct)`: resolve each ref → `SqlTagDefinition` (miss → `BadNodeIdUnknown`); `SqlGroupPlanner.Plan(...)`; per group under a `SemaphoreSlim(maxConcurrentGroups)`: `await using var conn = factory.CreateConnection(); conn.ConnectionString = connStr; await conn.OpenAsync(linkedCt)`; build `DbCommand` with `CommandTimeout = (int)commandTimeout.TotalSeconds` and bound params; `using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(operationTimeout); await using var rdr = await cmd.ExecuteReaderAsync(linked.Token)`; index rows by key/selector; map each member's cell → `DataValueSnapshot` (type per `DeclaredType` ?? `dialect.MapColumnType`-inferred; NULL → `nullIsBad ? Bad : Uncertain`; absent key → `BadNoData`; source timestamp from `timestampColumn` else read wall-clock). **Reassemble in input order** (`DataValueSnapshot[refs.Count]`). Per-tag failure = Bad-coded snapshot, NOT an exception; the whole call throws only if the connection itself is unreachable (→ engine backoff). `await using` both connection and reader — never leak. Add `SqlStatusCodes` static (BadNoData/BadTimeout/BadNodeIdUnknown/BadCommunicationError/Uncertain constants) if not reusing an existing helper.
|
||||
4. Run — expect PASS. Add a NULL-cell test with `nullIsBad:true` → `Bad`.
|
||||
5. Commit: `git commit -m "feat(sql): SqlPollReader — grouped read, bounded deadline, ordered slice-back"`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `SqlDriver` shell — IDriver / ITagDiscovery / IReadable / ISubscribable / IHostConnectivityProbe
|
||||
|
||||
**Classification:** high-risk (lifecycle + poll-engine wiring + connection validation)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — construct `SqlDriver` with an injected dialect + factory + already-resolved connection string (SQLite fixture) + authored `RawTagEntry` list; `InitializeAsync` → `Healthy`; `DiscoverAsync` streams one Variable per authored tag with `SecurityClass=ViewOnly` + `RediscoverPolicy=Once` + `SupportsOnlineDiscovery=false`; `ReadAsync` delegates to the reader; a subscribe fires an initial change:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Initialize_thenDiscover_streamsAuthoredTagsReadOnly()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync();
|
||||
var driver = SqlDriver.ForTest(fx, rawTags: fx.AuthoredRawTags);
|
||||
await driver.InitializeAsync(fx.ConfigJson, CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
((ITagDiscovery)driver).SupportsOnlineDiscovery.ShouldBeFalse();
|
||||
((ITagDiscovery)driver).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
|
||||
var cap = new CapturingBuilder();
|
||||
await ((ITagDiscovery)driver).DiscoverAsync(cap, CancellationToken.None);
|
||||
cap.Variables.ShouldContain(v => v.FullName == "Speed" && v.SecurityClass == SecurityClassification.ViewOnly);
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlDriver : IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable`. Mirror `ModbusDriver`: fields grouped up top; `_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(...)`; `_poll = new PollGroupEngine(reader: ReadAsync, onChange: ..., onError: HandlePollError, backoffCap: 30s)`. `InitializeAsync`: build `_tagsByRawPath` from `_options.RawTags` via `SqlEquipmentTagParser.TryParse` (miss = logged skip, never throw); open ONE connection, run `dialect.LivenessSql` under `CommandTimeout` + linked CTS, dispose; success → `Healthy`, failure → `Faulted` + `LastError`. `ReadAsync` → `_reader.ReadAsync`. `SubscribeAsync` → `_poll.Subscribe`; `UnsubscribeAsync` → `_poll.Unsubscribe`. `IHostConnectivityProbe`: single logical host (server host from the connection string) with `Running/Stopped` from the last poll/liveness outcome + `OnHostStatusChanged` on transitions. `GetMemoryFootprint => 0`; `FlushOptionalCachesAsync => Task.CompletedTask`; `ReinitializeAsync` = teardown+init. `DriverType => DriverTypeNames.Sql`. Add a `ForTest(...)` internal factory that injects dialect+factory+connString (production path resolves those in the factory, Task 9). **Pooling: open-use-dispose per poll (already in the reader); no long-lived connection.**
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlDriver shell wiring PollGroupEngine + probe + read-only discovery"`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: `SqlDriverFactoryExtensions` + `connectionStringRef` env resolution + enum-serialization guard
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs`
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlConnectionStringResolver.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverFactoryTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — `CreateInstance` with a config carrying `"provider":"SqlServer"` builds a driver; a missing `connectionStringRef` throws a clean error; the connection-string ref resolves from env `Sql__ConnectionStrings__<ref>`; **enum-serialization guard** — a config author serializing `provider` as a NUMBER still parses (`JsonStringEnumConverter` accepts ordinals) AND the round-trip writes the NAME:
|
||||
```csharp
|
||||
[Fact]
|
||||
public void CreateInstance_missingConnectionStringRef_throwsActionable()
|
||||
=> Should.Throw<InvalidOperationException>(() =>
|
||||
SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null))
|
||||
.Message.ShouldContain("connectionStringRef");
|
||||
|
||||
[Fact]
|
||||
public void ConnectionStringRef_resolvesFromEnv()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("Sql__ConnectionStrings__MesStaging", "Server=x;Database=y;");
|
||||
SqlConnectionStringResolver.Resolve("MesStaging").ShouldBe("Server=x;Database=y;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_serializesAsNameNotNumber()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new SqlDriverConfigDto { Provider = SqlProvider.SqlServer },
|
||||
SqlDriverFactoryExtensions.JsonOptionsForTest);
|
||||
json.ShouldContain("\"SqlServer\"");
|
||||
json.ShouldNotContain("\"provider\":0");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlConnectionStringResolver.Resolve(string @ref)` → `Environment.GetEnvironmentVariable($"Sql__ConnectionStrings__{@ref}")` (direct env read, deliberate — the factory closure has no `IConfiguration`, per design §8.2), throws actionable if absent. `SqlDriverFactoryExtensions`: `const DriverTypeName = DriverTypeNames.Sql`; `Register(registry, loggerFactory)` → `registry.Register(DriverTypeName, (id,json)=>CreateInstance(id,json,loggerFactory))`; `CreateInstance` deserializes `SqlDriverConfigDto` with `JsonOptions` carrying `JsonStringEnumConverter` + `UnmappedMemberHandling.Skip`, validates `ConnectionStringRef` present, builds `SqlServerDialect` from `Provider` (P1: only `SqlServer` constructible; others throw "provider not available in this build"), resolves the connection string, constructs the `SqlPollReader` + `SqlDriver`. **Never log the resolved connection string** (log provider + server host + database only). Expose `JsonOptionsForTest` internal.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): factory + connectionStringRef env resolution + string-enum guard"`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: `SqlDriverProbe` (SELECT 1 liveness, bounded timeout)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 5, Task 9, Task 12
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlDriverProbeTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — against the SQLite fixture (inject dialect+factory), `ProbeAsync` returns green with latency; a bad connection string returns a red result with the error message (never throws):
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Probe_liveConnection_returnsGreen()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync();
|
||||
var probe = SqlDriverProbe.ForTest(fx.Factory, new SqliteDialect());
|
||||
var r = await probe.ProbeAsync(fx.ConfigJson, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
r.Success.ShouldBeTrue();
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlDriverProbe : IDriverProbe`, `DriverType => DriverTypeNames.Sql`. Parse config via the SAME factory DTO shape + `JsonStringEnumConverter` (R2-11 factory parity, mirroring `ModbusDriverProbe`), resolve the connection string ref, open a connection under a linked CTS bounded by `timeout`, run `dialect.LivenessSql` (`SELECT 1`), return `DriverProbeResult(true, "SQL SELECT 1 OK", latency)`; catch → red result naming the failure. Never throws. `ForTest` injects factory+dialect for the SQLite path.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlDriverProbe SELECT-1 liveness check"`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Host registration — `DriverTypeNames.Sql` + factory + probe + guard test
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj` (project-ref `Driver.Sql` if not transitively present)
|
||||
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs` is EXISTING — it asserts bidirectional parity between `DriverTypeNames` constants and the factory-registered set; this task makes it pass by adding both sides.
|
||||
|
||||
**TDD:**
|
||||
1. **Failing step** — add `public const string Sql = "Sql";` to `DriverTypeNames` + include in `All`, but DON'T yet register the factory. Run `DriverTypeNamesGuardTests` → expect FAIL (constant with no registered factory).
|
||||
2. **Impl** — in `DriverFactoryBootstrap.Register(...)` add `Driver.Sql.SqlDriverFactoryExtensions.Register(registry, loggerFactory);`; in `AddOtOpcUaDriverProbes` add `services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, SqlProbe>());` with a `using SqlProbe = Driver.Sql.SqlDriverProbe;` alias; add the `Driver.Sql` project-ref to the Host csproj. Default tier `DriverTier.A` (no explicit arg — SQL client is cross-platform managed, runs on macOS dev, so `ShouldStub()` needs no change).
|
||||
3. Run guard test + `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): register Sql factory + probe in Host, add DriverTypeNames.Sql"`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Scaffold `Driver.Sql.Browser` project + register in slnx
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 5, Task 6, Task 9, Task 10
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj`
|
||||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx`
|
||||
|
||||
**Steps:**
|
||||
1. csproj refs `Commons` (`ZB.MOM.WW.OtOpcUa.Commons` — `IDriverBrowser`/`IBrowseSession`/`BrowseNode`) + `Driver.Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is the deliberate deviation from `OpcUaClient.Browser` recorded in design §2 — add an XML comment on the ref citing the design so nobody "fixes" it. `InternalsVisibleTo` the `.Browser.Tests`.
|
||||
2. Add to slnx under `/src/Drivers/`.
|
||||
3. Build — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): scaffold Driver.Sql.Browser project"`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: `SqlBrowseSession` — schema walk over the dialect catalog
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests.csproj` (+ slnx entry, + link `SqliteDialect.cs` from Sql.Tests)
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlBrowseSessionTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — over a seeded SQLite DB + `SqliteDialect`: `RootAsync` yields schema folder(s); `ExpandAsync(schema)` yields the seeded tables; `ExpandAsync(table)` yields columns as leaves; `AttributesAsync(column)` yields the mapped `DriverDataType`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ExpandTable_yieldsColumnsAsLeaves_withMappedType()
|
||||
{
|
||||
await using var db = await SqliteBrowseFixture.CreateAsync(); // TagValues(tag_name TEXT, num_value REAL)
|
||||
var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
|
||||
var tableNode = (await session.ExpandAsync(db.SchemaNodeId, default)).First(n => n.DisplayName.Contains("TagValues"));
|
||||
var cols = await session.ExpandAsync(tableNode.NodeId, default);
|
||||
cols.ShouldContain(c => c.DisplayName == "num_value" && c.IsLeaf);
|
||||
var attrs = await session.AttributesAsync(cols.First(c => c.DisplayName == "num_value").NodeId, default);
|
||||
attrs.DriverDataType.ShouldBe(nameof(DriverDataType.Float64));
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlBrowseSession : IBrowseSession` (match the `Commons.Browsing` interface shape used by `OpcUaClientBrowseSession`): `RootAsync`/`ExpandAsync(nodeId)`/`AttributesAsync(nodeId)` run the dialect catalog queries with bound `@schema`/`@table` params; encode `NodeId` as `schema` / `schema.table` / `schema.table|column`; a `SemaphoreSlim _gate` serializes (one ADO.NET connection is not concurrent); per-call work bounded by the AdminUI's existing 20 s per-call timeout. Column leaf `AttributesAsync` returns `AttributeInfo(Name: column, DriverDataType: dialect.MapColumnType(dataType).ToString(), IsArray:false, SecurityClass:"ViewOnly")` (mirrors Galaxy two-stage pick).
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlBrowseSession schema-walk over dialect catalog"`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: `SqlDriverBrowser` — IDriverBrowser open (env ref OR pasted literal, transient connection)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17
|
||||
**Files:**
|
||||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs`
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/SqlDriverBrowserTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — `OpenAsync` with a form JSON whose `connectionStringRef` resolves from env opens a session; an UNresolvable ref fails with a message naming the EXACT missing env var; a pasted literal connection string is used session-only (never persisted):
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Open_unresolvableRef_failsNamingExactEnvVar()
|
||||
{
|
||||
var browser = new SqlDriverBrowser();
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
browser.OpenAsync("""{"provider":"SqlServer","connectionStringRef":"MesStaging"}""", default));
|
||||
ex.Message.ShouldContain("Sql__ConnectionStrings__MesStaging");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlDriverBrowser : IDriverBrowser`, `DriverType => DriverTypeNames.Sql`. `OpenAsync(configJson, ct)`: deserialize with the same `JsonStringEnumConverter` options; resolve `connectionStringRef` via a direct env read (`Sql__ConnectionStrings__<ref>`) IN THE ADMINUI PROCESS — on miss throw an actionable message naming the exact missing env var (design §4.4); accept an optional pasted literal `connectionString` for ad-hoc browse (session-only, **never persisted, never logged, no `_lastConfigJson` cached field**); select `SqlServerDialect` (P1); open ONE transient `DbConnection`, return a `SqlBrowseSession`. Reuses the AdminUI `BrowseSessionRegistry` + reaper + idle TTL unchanged.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): SqlDriverBrowser transient-connection open with env-ref + literal"`
|
||||
|
||||
---
|
||||
|
||||
## Task 15: AdminUI browser DI + `SqlAddressPickerBody.razor` (picker body)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs` (add `services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();` beside the OpcUaClient/Galaxy lines ~:75)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (project-ref `Driver.Sql.Browser`)
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddressPickers/SqlAddressPickerBody.razor` (reuse `DriverBrowseTree.razor` for the tree + an attribute side-panel + model/selector fields; gated by the existing `DriverOperator` policy; manual entry retained)
|
||||
|
||||
**Steps (no unit test — Razor is live-verified in Task 21):**
|
||||
1. Register the bespoke `IDriverBrowser` (the universal-browser DI line is NOT added for `Sql` — its `CanBrowse` is false and bespoke wins by construction).
|
||||
2. `SqlAddressPickerBody.razor` — thin body: `DriverBrowseTree` in picker mode + attribute side-panel; on column pick, compose the `TagConfig` blob (DisplayName default `table.column`, prefill `type` from `AttributesAsync`, operator picks model + fills key/row selector; `SecurityClass=ViewOnly`).
|
||||
3. `dotnet build` — expect PASS.
|
||||
4. Commit: `git commit -m "feat(sql): AdminUI browser DI + Sql address picker body"`
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Env-gated central-SQL integration fixture (`Driver.Sql.IntegrationTests`)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 13, Task 14, Task 15, Task 17
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj` (+ slnx entry)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing/skipping test** — against the always-on central SQL Server (`10.100.0.35,14330`), seed a `SqlPollFixture` database with the two sample tables (KeyValue + wide-row), then validate the real `Microsoft.Data.SqlClient` path (read round-trip, `INFORMATION_SCHEMA` browse, `CommandTimeout`/cancellation, pooling). **Env-gated** via `SQL_TEST_ENDPOINT` / a full `Sql__ConnectionStrings__Fixture` — absent ⇒ skip cleanly (use `Assert.Skip(...)` / xunit.v3 `[Fact(Skip=...)]` dynamic skip), like every other `*.IntegrationTests`:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue()
|
||||
{
|
||||
var connStr = Environment.GetEnvironmentVariable("Sql__ConnectionStrings__Fixture");
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(connStr), "Sql__ConnectionStrings__Fixture not set — offline skip.");
|
||||
await using var fx = await SqlPollServerFixture.EnsureSeededAsync(connStr!);
|
||||
var driver = SqlDriver.ForProduction(connStr!, new SqlServerDialect(), fx.AuthoredRawTags);
|
||||
await driver.InitializeAsync(fx.ConfigJson, default);
|
||||
var snaps = await driver.ReadAsync(new[] { "Line1.Speed" }, default);
|
||||
snaps[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
```
|
||||
2. Run offline — expect SKIP (green).
|
||||
3. **Impl** — the fixture (create-if-missing DB + tables + seed rows via `Microsoft.Data.SqlClient`) and the read tests. **The seed DB is `SqlPollFixture`, NOT `ConfigDb`** — the shared central server hosts ConfigDb; the fixture uses its own database on that server.
|
||||
4. Commit: `git commit -m "test(sql): env-gated central-SQL integration fixture + read round-trip"`
|
||||
|
||||
---
|
||||
|
||||
## Task 17: Injection regression test (bind-harmless value / reject identifier, never execute)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 13, Task 14, Task 15, Task 16
|
||||
**Files:**
|
||||
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqlInjectionRegressionTests.cs`
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** (offline, SQLite fixture) — a malicious `keyValue` (`'; DROP TABLE TagValues; --`) reads harmlessly as a bound param and the seed table still exists afterward; a malicious `table`/`column` identifier that is not catalog-valid is REJECTED (tag → `BadNodeIdUnknown`), never executed:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
|
||||
{
|
||||
await using var fx = await SqlitePollFixture.CreateAsync();
|
||||
var reader = fx.NewReader();
|
||||
var snaps = await reader.ReadAsync(new[] { fx.RefWithKeyValue("'; DROP TABLE TagValues; --") }, default);
|
||||
snaps[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no such key — bound, not executed
|
||||
(await fx.TableRowCountAsync("TagValues")).ShouldBeGreaterThan(0); // table intact
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL (until reader binds correctly; it should already PASS from Task 7 if binding is right — this test LOCKS the guarantee).
|
||||
3. **Impl** — none if Task 7 binds correctly; otherwise fix. Add a review-checklist note: "any code path building SQL by string-concatenating a tag field is a defect."
|
||||
4. Commit: `git commit -m "test(sql): injection regression — bind values, reject unknown identifiers"`
|
||||
|
||||
---
|
||||
|
||||
## Task 18: Blackhole / timeout live-gate against a DEDICATED mssql container
|
||||
|
||||
**Classification:** high-risk (frozen-peer resilience — the single highest-value integration test)
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/docker-compose.yml` (a dedicated disposable `mssql` stack)
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/seed.sql`
|
||||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlBlackholeTimeoutTests.cs`
|
||||
|
||||
> **CRITICAL:** this gate `docker pause`s a **dedicated** `mcr.microsoft.com/mssql/server` container it owns — **NEVER** the shared central SQL Server on `10.100.0.35,14330`, which hosts `ConfigDb` for the whole rig (design §9). The compose stack is deployed via `lmxopcua-fix sync`; `lmxopcua-fix` applies the `project=lmxopcua` label host-side.
|
||||
|
||||
**TDD:**
|
||||
1. **Failing/skipping test** — mirror the R2-01 S7 blackhole (`S7_1500ConnectTimeoutOutageTests`): start reading against the dedicated mssql, then `docker pause` it mid-poll; assert the next read surfaces `BadTimeout` within `operationTimeout` + a small margin (client-side linked-CTS cancellation, not the poll thread wedging), the driver degrades, and the poll loop backs off; `docker unpause` recovers. Env-gated (`SQL_BLACKHOLE_ENDPOINT`) — offline skip:
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task PausedServer_surfacesBadTimeout_withinDeadline_notWedged()
|
||||
{
|
||||
var ep = Environment.GetEnvironmentVariable("SQL_BLACKHOLE_ENDPOINT");
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(ep), "SQL_BLACKHOLE_ENDPOINT not set — offline skip.");
|
||||
// ... start driver, docker pause <dedicated-container>, read, assert BadTimeout within ~operationTimeout, unpause ...
|
||||
}
|
||||
```
|
||||
2. Run offline — expect SKIP.
|
||||
3. **Impl** — dedicated `docker-compose.yml` (`mssql` service, own container name, `restart: "no"`), `seed.sql` (the two sample tables), and the test that shells `docker pause`/`docker unpause` against the OWNED container. Assert the wall-clock bound (the frozen-peer contract): `CommandTimeout` (server-side backstop) + `ExecuteReaderAsync(linkedCt)` bounded by `operationTimeout` (client-side real cancellation) both fire.
|
||||
4. Commit: `git commit -m "test(sql): blackhole/timeout live-gate on a dedicated mssql container"`
|
||||
|
||||
---
|
||||
|
||||
## Task 19: AdminUI typed editor model + validator (timeout-order rule, string enums)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 16, Task 17, Task 18
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/SqlTagConfigModel.cs`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (add `[DriverTypeNames.Sql] = typeof(...SqlTagConfigEditor)`)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs` (add `[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate()`)
|
||||
- Test: `tests/.../AdminUI.Tests/.../SqlTagConfigModelTests.cs` (locate the existing AdminUI tag-editor test project; mirror `ModbusTagConfigModel` tests)
|
||||
|
||||
**TDD:**
|
||||
1. **Failing test** — `FromJson`→`ToJson` round-trips the model, preserves unknown keys, writes enums (`model`/`type`) as NAME strings not numbers; `Validate()` rejects a WideRow without a row selector and enforces the driver-level timeout-order rule surrogate at the tag layer if the tag carries timeouts (the primary timeout-order check is at the driver page — the model validates model/selector shape):
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ToJson_writesModelAndTypeAsStrings_preservesUnknownKeys()
|
||||
{
|
||||
var m = SqlTagConfigModel.FromJson("""{"driver":"Sql","model":"KeyValue","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Float64","customX":1}""");
|
||||
var json = m.ToJson();
|
||||
json.ShouldContain("\"KeyValue\"");
|
||||
json.ShouldContain("\"Float64\"");
|
||||
json.ShouldContain("customX"); // unknown key survives load→save
|
||||
json.ShouldNotContain("\"model\":0");
|
||||
}
|
||||
```
|
||||
2. Run — expect FAIL.
|
||||
3. **Minimal impl** — `SqlTagConfigModel` (thin, over `TagConfigJson.ParseOrNew`, preserving the `_bag`): `Model`, `Table`, `KeyColumn`/`KeyValue`/`ValueColumn`/`TimestampColumn`, `ColumnName` + `RowSelector`, `Type` (`DriverDataType?`), `FromJson`/`ToJson` (enums as name strings via `TagConfigJson.Set`) `/Validate` (model discriminator ⇒ required-field shape; WideRow needs a selector). Register in `TagConfigEditorMap` + `TagConfigValidator`. **Enum-serialization trap (systemic):** `model`/`type` MUST round-trip as strings on both sides — the editor `ToJson` and the factory `SqlEquipmentTagParser` both use string enums; add the assertion above.
|
||||
4. Run — expect PASS.
|
||||
5. Commit: `git commit -m "feat(sql): typed AdminUI Sql tag-config model + validator (string enums)"`
|
||||
|
||||
---
|
||||
|
||||
## Task 20: `SqlTagConfigEditor.razor` (thin shell over the model)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/SqlTagConfigEditor.razor`
|
||||
|
||||
**Steps (Razor — live-verified in Task 21):**
|
||||
1. Copy the Modbus editor template; bind over `SqlTagConfigModel`: a `Model` dropdown (KeyValue/WideRow) that shows the matching field group, `Table`, the key/value/timestamp fields (KeyValue) OR `ColumnName` + row-selector fields (WideRow), and a `Type` override dropdown. `@bind` string component params need the `@` prefix (the Global-UNS gotcha).
|
||||
2. `dotnet build` — expect PASS.
|
||||
3. Commit: `git commit -m "feat(sql): SqlTagConfigEditor razor shell"`
|
||||
|
||||
---
|
||||
|
||||
## Task 21: Live `/run` verification on docker-dev (picker + editor + deploy + read)
|
||||
|
||||
**Classification:** trivial (procedure — no product code; may surface fix-forward tasks)
|
||||
**Estimated implement time:** ~5 min (verification, excluding any bugs found)
|
||||
**Parallelizable with:** none (last)
|
||||
**Files:** none (verification only; any defect becomes a new fix-forward task)
|
||||
|
||||
**Procedure (docker-dev rig, AdminUI auto-authenticated at `http://localhost:9200` — login disabled, so drive it yourself, don't defer to the user):**
|
||||
1. Set `Sql__ConnectionStrings__DevSql` on the docker-dev central + driver nodes pointing at a dev SQL Server + seeded table (the dedicated mssql from Task 18, or the central-SQL fixture DB). Rebuild BOTH central-1/central-2 (`:9200` round-robins).
|
||||
2. In `/raw`, add a `Sql` driver + device (paste a literal connection string or use the ref); Test-Connect → green (`SELECT 1`).
|
||||
3. Open the Sql address picker → browse schema→table→column → commit a KeyValue tag and a WideRow tag; confirm the typed editor renders and validates.
|
||||
4. Reference the raw tags into an equipment on `/uns`; deploy via `POST :9200/api/deployments` (X-Api-Key) or the deploy button; confirm the deployment seals green.
|
||||
5. Read via Client.CLI (`read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath>"`) — confirm the live SQL value + quality + source timestamp.
|
||||
6. Record the run outcome in the plan's completion note. If anything is deploy-inert / mis-bound, open a fix-forward task (Razor binding + deploy-inertness bugs pass unit tests + review — this live gate is the real proof).
|
||||
|
||||
---
|
||||
|
||||
## Deferred / out of scope (pointers to the design, NOT executable tasks here)
|
||||
|
||||
These are recorded so nobody re-derives them; each is a later phase in `docs/plans/2026-07-15-sql-poll-driver-design.md`. The `ISqlDialect` seam is built in from day one (Task 4) precisely so the provider tail below is additive and cheap.
|
||||
|
||||
- **Named-query model** (design §5.4, §3.6(c) / design-P3) — the arbitrary-`SELECT` escape hatch (`queries: {...}` + a tag referencing a query by name + projection). Not built in v1; the `SqlTagModel.Query` enum member exists but the reader/planner path is deferred.
|
||||
- **PostgreSQL + ODBC dialects** (design §2.2, §8.5 / design-P3) — `PostgresDialect` (`Npgsql`) + `OdbcDialect` (`System.Data.Odbc`), each a NEW gated `PackageVersion`. v1 constructs only `SqlServerDialect`; the other `SqlProvider` members throw "provider not available in this build."
|
||||
- **MySQL/MariaDB + native Oracle** (design §10 / design-P5) — `MySqlConnector` + an `ALL_TAB_COLUMNS` Oracle dialect. Demand-driven.
|
||||
- **Write mode (`IWritable`)** (design §3.4 / design-P4) — opt-in, triple-gated (`WriteOperate` role + driver `allowWrites` master switch + `TagConfig.writable`), parameterized UPSERT/StoredProc, `WriteIdempotent` retry policy. v1 is read-only; `SecurityClass=ViewOnly` everywhere and `allowWrites` defaults `false`.
|
||||
- **Split-role env-parity operational note** (design §4.4) — admin nodes must carry the same `Sql__ConnectionStrings__<ref>` env vars as driver nodes for any browseable ref; the browser-open failure names the exact missing env var. This is a deployment/runbook item, not a code task.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-sql-poll-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]},
|
||||
{"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]},
|
||||
{"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]},
|
||||
{"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]},
|
||||
{"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]},
|
||||
{"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]},
|
||||
{"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
}
|
||||
+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
@@ -0,0 +1,263 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
/// <summary>
|
||||
/// One <c>(edgeNode, device)</c> scope's metric catalog, as declared by its most recent
|
||||
/// NBIRTH/DBIRTH: the alias→metric cache <b>and</b> the stable name→metric index, rebuilt wholesale
|
||||
/// on every birth. Design doc §3.6 invariants #1 and #2.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The alias is a per-birth cache; the NAME is the binding key.</b> Sparkplug's alias
|
||||
/// mechanism exists to shrink DATA payloads — a BIRTH declares <c>name ↔ alias</c> and every
|
||||
/// subsequent DATA metric carries only the alias — and the alias is scoped to <i>that one
|
||||
/// birth</i>. After a rebirth an edge node is entirely free to point alias 5 at a different
|
||||
/// metric. So an authored tag binds by <see cref="SparkplugMetricBinding.Name"/>; the alias is
|
||||
/// only ever the lookup that turns an incoming DATA metric back into a name. Binding a tag to
|
||||
/// an alias instead means a rebirth silently starts routing Pressure into a Temperature tag —
|
||||
/// <b>good quality, plausible values, completely wrong</b>, and invisible unless something
|
||||
/// specifically reuses an alias across a rebirth.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="RebuildFromBirth"/> REPLACES; it never merges or upserts.</b> The whole
|
||||
/// snapshot — both indexes — is built fresh and published in one assignment. An alias the new
|
||||
/// birth did not declare is gone, including when the new birth declares no metrics at all. This
|
||||
/// is the single behaviour the type exists to guarantee: a merge (or an "optimisation" that
|
||||
/// skips an empty birth) reintroduces exactly the stale-alias mis-route above.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both indexes are replaced together, from the same birth.</b> They are deliberately held
|
||||
/// in one type rather than split across an "alias table" and a separate "birth cache": they are
|
||||
/// two views of one birth, and any seam between them is somewhere the two can be replaced
|
||||
/// independently and skew — an alias resolving through the new birth into a name catalog still
|
||||
/// holding the old one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Thread-safety: an immutable snapshot swapped atomically</b>, the same discipline
|
||||
/// <c>MqttSubscriptionManager.AuthoredTable</c> uses and for the same reason — this is read on
|
||||
/// MQTTnet's shared dispatcher thread as messages arrive, and a reader must never see a
|
||||
/// half-rebuilt map. No lock is taken on any path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Last values do not live here.</b> The running last-observed value per tag is
|
||||
/// <see cref="LastValueCache"/>'s, keyed by RawPath — the identity every publish in this driver
|
||||
/// is made under. A second copy keyed by metric name would be a duplicate source of truth with
|
||||
/// a different key, and it would grow with every metric the plant births whether or not any tag
|
||||
/// was ever authored for it. A birth's own values flow straight through the ingest path to that
|
||||
/// cache; nothing is retained here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AliasTable
|
||||
{
|
||||
private volatile Snapshot _snapshot = Snapshot.Empty;
|
||||
|
||||
/// <summary>How many distinct metrics the most recent birth declared.</summary>
|
||||
public int Count => _snapshot.Metrics.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Every metric the most recent birth declared. The consumer's STALE fan-out on a death, and
|
||||
/// the discovery path's "which metrics does this scope have" question, both read this.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SparkplugMetricBinding> Metrics => _snapshot.Metrics;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces this scope's whole catalog with <paramref name="birthMetrics"/> — the metrics of one
|
||||
/// NBIRTH/DBIRTH payload, exactly as <see cref="SparkplugCodec"/> projected them.
|
||||
/// </summary>
|
||||
/// <param name="birthMetrics">The birth payload's metrics.</param>
|
||||
/// <returns>
|
||||
/// What was installed, and what was refused — the caller's only window onto a malformed birth,
|
||||
/// since this type takes no logger.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Only an unusable NAME is a rejection.</b> A metric with a blank/absent name cannot be
|
||||
/// bound to an authored tag by any route, and storing it alias-only would create a binding
|
||||
/// that can never resolve (or, worse, one that matches a tag whose authored metric name
|
||||
/// happens to be blank), so it is dropped and counted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A missing datatype is NOT a rejection</b> — it becomes
|
||||
/// <see cref="TahuDataType.Unknown"/>. Dropping such a metric would turn every DATA message
|
||||
/// for its alias into an unknown-alias event, which the ingest state machine answers with a
|
||||
/// rebirth request; the edge node would answer with the same malformed birth, and the pair
|
||||
/// would loop. Kept as Unknown, the typed layer refuses the value once
|
||||
/// (<c>ToDriverDataType()</c> returns null for it) and nothing storms.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An alias claimed by two metrics in one birth resolves to neither.</b> Any choice
|
||||
/// between them is a coin flip between two different signals, which is the mis-route this
|
||||
/// type exists to prevent; the alias is dropped (the consumer sees an unknown alias and can
|
||||
/// request a rebirth) while both metrics stay bindable by name. A duplicate <i>name</i>, by
|
||||
/// contrast, is last-wins: the name is the binding key, so evicting it would take the
|
||||
/// authored tag dark entirely.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public BirthApplyResult RebuildFromBirth(IEnumerable<SparkplugMetric> birthMetrics)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(birthMetrics);
|
||||
|
||||
var byName = new Dictionary<string, SparkplugMetricBinding>(StringComparer.Ordinal);
|
||||
var byAlias = new Dictionary<ulong, SparkplugMetricBinding>();
|
||||
HashSet<ulong>? collidedAliases = null;
|
||||
var rejectedUnnamed = 0;
|
||||
var duplicateNames = 0;
|
||||
var aliasCollisions = 0;
|
||||
|
||||
foreach (var metric in birthMetrics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(metric.Name))
|
||||
{
|
||||
rejectedUnnamed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var binding = new SparkplugMetricBinding(metric.Name, metric.Alias, metric.DataType ?? TahuDataType.Unknown);
|
||||
|
||||
if (!byName.TryAdd(binding.Name, binding))
|
||||
{
|
||||
duplicateNames++;
|
||||
byName[binding.Name] = binding;
|
||||
}
|
||||
|
||||
if (binding.Alias is { } alias && !byAlias.TryAdd(alias, binding))
|
||||
{
|
||||
aliasCollisions++;
|
||||
(collidedAliases ??= []).Add(alias);
|
||||
}
|
||||
}
|
||||
|
||||
if (collidedAliases is not null)
|
||||
{
|
||||
foreach (var alias in collidedAliases)
|
||||
{
|
||||
byAlias.Remove(alias);
|
||||
}
|
||||
}
|
||||
|
||||
// A duplicate name is last-wins in `byName`, so the alias index can still hold the losing
|
||||
// binding — drop those too rather than let an alias resolve to a name that resolves elsewhere.
|
||||
if (duplicateNames > 0)
|
||||
{
|
||||
foreach (var alias in byAlias.Where(kv => !ReferenceEquals(byName[kv.Value.Name], kv.Value))
|
||||
.Select(kv => kv.Key).ToList())
|
||||
{
|
||||
byAlias.Remove(alias);
|
||||
}
|
||||
}
|
||||
|
||||
// THE replace. One assignment of a fully-built snapshot — no merge, no upsert, and no
|
||||
// short-circuit for an empty birth (an empty birth legitimately clears the scope).
|
||||
_snapshot = new Snapshot(
|
||||
byAlias.ToFrozenDictionary(),
|
||||
byName.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
[.. byName.Values]);
|
||||
|
||||
return new BirthApplyResult(byName.Count, rejectedUnnamed, aliasCollisions, duplicateNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a DATA metric's alias to the metric the <b>most recent</b> birth bound it to, or
|
||||
/// <see langword="null"/> when this birth declared no such alias.
|
||||
/// </summary>
|
||||
/// <param name="alias">The alias carried by an inbound DATA metric.</param>
|
||||
/// <returns>The binding, or <see langword="null"/> — which the consumer treats as an unknown alias.</returns>
|
||||
public SparkplugMetricBinding? Resolve(ulong alias) =>
|
||||
_snapshot.ByAlias.TryGetValue(alias, out var binding) ? binding : null;
|
||||
|
||||
/// <summary>The <see cref="Resolve(ulong)"/> try-shape.</summary>
|
||||
/// <param name="alias">The alias carried by an inbound DATA metric.</param>
|
||||
/// <param name="binding">The binding when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when the most recent birth declared this alias.</returns>
|
||||
public bool TryResolve(ulong alias, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
|
||||
{
|
||||
binding = Resolve(alias);
|
||||
return binding is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves by the stable metric name — the binding key. Used for a DATA metric that carried its
|
||||
/// name rather than an alias (aliases are optional in Sparkplug), and to answer "does this scope
|
||||
/// publish the metric this tag was authored against".
|
||||
/// </summary>
|
||||
/// <param name="name">The stable metric name. Matched ordinally; Sparkplug names are case-sensitive.</param>
|
||||
/// <returns>The binding, or <see langword="null"/>.</returns>
|
||||
public SparkplugMetricBinding? ResolveByName(string name) =>
|
||||
!string.IsNullOrEmpty(name) && _snapshot.ByName.TryGetValue(name, out var binding) ? binding : null;
|
||||
|
||||
/// <summary>The <see cref="ResolveByName"/> try-shape.</summary>
|
||||
/// <param name="name">The stable metric name.</param>
|
||||
/// <param name="binding">The binding when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when the most recent birth declared this metric.</returns>
|
||||
public bool TryResolveByName(string name, [NotNullWhen(true)] out SparkplugMetricBinding? binding)
|
||||
{
|
||||
binding = ResolveByName(name);
|
||||
return binding is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One birth's catalog, immutable and published in a single assignment so the dispatcher thread
|
||||
/// always reads one whole birth's worth of state.
|
||||
/// </summary>
|
||||
/// <param name="ByAlias">Alias → binding, for the birth that declared it. Collided aliases are absent.</param>
|
||||
/// <param name="ByName">Stable metric name → binding — the index authored tags bind through.</param>
|
||||
/// <param name="Metrics">Every declared metric, for the STALE fan-out and discovery.</param>
|
||||
private sealed record Snapshot(
|
||||
FrozenDictionary<ulong, SparkplugMetricBinding> ByAlias,
|
||||
FrozenDictionary<string, SparkplugMetricBinding> ByName,
|
||||
SparkplugMetricBinding[] Metrics)
|
||||
{
|
||||
public static readonly Snapshot Empty = new(
|
||||
FrozenDictionary<ulong, SparkplugMetricBinding>.Empty,
|
||||
FrozenDictionary<string, SparkplugMetricBinding>.Empty,
|
||||
[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One metric as its birth declared it: the stable name, the per-birth alias, the datatype.</summary>
|
||||
/// <param name="Name">
|
||||
/// The stable metric name — <b>the binding key</b>. An authored tag's <c>metricName</c> matches
|
||||
/// against this, never against <paramref name="Alias"/>.
|
||||
/// </param>
|
||||
/// <param name="Alias">
|
||||
/// The alias this birth assigned, or <see langword="null"/> when the birth declared none (legal —
|
||||
/// such a publisher sends names in DATA too). <b>Valid only until the next birth for this scope.</b>
|
||||
/// </param>
|
||||
/// <param name="DataType">
|
||||
/// The datatype the birth declared, or <see cref="TahuDataType.Unknown"/> when it declared none.
|
||||
/// This is the <i>only</i> place a DATA metric's datatype can come from — DATA carries none.
|
||||
/// </param>
|
||||
public sealed record SparkplugMetricBinding(string Name, ulong? Alias, TahuDataType DataType)
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies <see cref="SparkplugCodec.ReinterpretSigned"/> to a raw DATA value using
|
||||
/// <b>this birth's</b> datatype.
|
||||
/// </summary>
|
||||
/// <param name="rawValue">The raw <see cref="SparkplugMetric.Value"/> from a DATA metric.</param>
|
||||
/// <returns>The signed value for a signed-integer datatype; <paramref name="rawValue"/> otherwise.</returns>
|
||||
/// <remarks>
|
||||
/// Exposed here because this is the one object that holds the datatype at the moment a DATA
|
||||
/// value is being resolved, and forgetting the call is silent: Sparkplug carries Int8/16/32/64
|
||||
/// as two's complement in an <i>unsigned</i> proto field, so a metric whose value is -42
|
||||
/// publishes as 4294967254 — plausible, Good quality, and invisible until someone reads a gauge.
|
||||
/// </remarks>
|
||||
public object? Reinterpret(object? rawValue) => SparkplugCodec.ReinterpretSigned(rawValue, DataType);
|
||||
}
|
||||
|
||||
/// <summary>What one <see cref="AliasTable.RebuildFromBirth"/> installed, and what it refused.</summary>
|
||||
/// <param name="Accepted">Distinct named metrics installed.</param>
|
||||
/// <param name="RejectedUnnamed">Metrics dropped for carrying no usable name.</param>
|
||||
/// <param name="AliasCollisions">Aliases claimed by more than one metric, and therefore left unresolvable.</param>
|
||||
/// <param name="DuplicateNames">Metrics whose name a later metric in the same birth overwrote.</param>
|
||||
public readonly record struct BirthApplyResult(
|
||||
int Accepted,
|
||||
int RejectedUnnamed,
|
||||
int AliasCollisions,
|
||||
int DuplicateNames)
|
||||
{
|
||||
/// <summary>Whether the birth was malformed in any way worth logging.</summary>
|
||||
public bool HasAnomaly => RejectedUnnamed > 0 || AliasCollisions > 0 || DuplicateNames > 0;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
/// <summary>
|
||||
/// The driver's live Sparkplug birth state: one <see cref="AliasTable"/> per
|
||||
/// <see cref="SparkplugScope"/>, with the spec's node→device invalidation rules applied on birth
|
||||
/// and on death. Design doc §3.6 invariants #2 and #4.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Scoping.</b> A scope is the full <c>(group, edgeNode, device?)</c> triple — never the
|
||||
/// device id alone and never <c>node/device</c> without the group, or one plant's
|
||||
/// <c>Filler1</c> would answer for another's. A node-scoped birth (NBIRTH) owns the metrics the
|
||||
/// edge node publishes itself; each DBIRTH owns its device's. They are separate scopes because
|
||||
/// Sparkplug gives them separate alias spaces: alias 5 on <c>EdgeA</c> and alias 5 on
|
||||
/// <c>EdgeA/Filler1</c> are unrelated metrics.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An NBIRTH invalidates every device under that node.</b> Per the Sparkplug spec an NBIRTH
|
||||
/// means the edge node (re)started and must re-publish a DBIRTH for each of its devices, so
|
||||
/// every previously published DBIRTH is void. Keeping a device's alias table across its node's
|
||||
/// rebirth is the same stale-alias mis-route as
|
||||
/// <see cref="AliasTable.RebuildFromBirth"/> guards against, one scope up — and it is easier to
|
||||
/// miss, because nothing about the DBIRTH itself changed. The invalidated devices are returned
|
||||
/// (<see cref="NodeBirthOutcome.InvalidatedDevices"/>) rather than merely dropped, so the
|
||||
/// consumer can fan STALE out over their metrics without a lookup against a table that has
|
||||
/// already been evicted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A death evicts, it does not blank.</b> After an NDEATH/DDEATH the scope is <i>absent</i>,
|
||||
/// which is what makes a DATA message arriving before the next birth a data-before-birth event
|
||||
/// (⇒ request a rebirth, §3.6 invariant #3) rather than a silent unknown-alias drop against a
|
||||
/// table that still looks alive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Thread-safety.</b> A <see cref="ConcurrentDictionary{TKey,TValue}"/> of scopes, each
|
||||
/// holding an <see cref="AliasTable"/> whose own state is an atomically swapped immutable
|
||||
/// snapshot. A rebirth <i>rebuilds the existing table in place</i> rather than replacing the
|
||||
/// table object, so a consumer holding a reference to a scope's table always observes the newest
|
||||
/// birth instead of quietly reading a detached older one. No lock is taken on any path — this is
|
||||
/// read (and written) on MQTTnet's shared dispatcher thread.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not a value store.</b> See the remarks on <see cref="AliasTable"/>: running values belong
|
||||
/// to <see cref="LastValueCache"/>, keyed by RawPath.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class BirthCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<SparkplugScope, AliasTable> _scopes = new();
|
||||
|
||||
/// <summary>How many scopes currently hold a live birth.</summary>
|
||||
public int Count => _scopes.Count;
|
||||
|
||||
/// <summary>A snapshot of the scopes currently holding a live birth.</summary>
|
||||
public IReadOnlyList<SparkplugScope> Scopes => [.. _scopes.Keys];
|
||||
|
||||
/// <summary>
|
||||
/// Applies an NBIRTH: rebuilds the edge node's own catalog and <b>invalidates every device</b>
|
||||
/// under it, per the Sparkplug spec.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The Sparkplug group id.</param>
|
||||
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
||||
/// <param name="birthMetrics">The NBIRTH payload's metrics.</param>
|
||||
/// <returns>The node's apply result plus the devices this birth invalidated, with their metrics.</returns>
|
||||
public NodeBirthOutcome ApplyNodeBirth(string groupId, string edgeNodeId, IEnumerable<SparkplugMetric> birthMetrics)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
|
||||
ArgumentNullException.ThrowIfNull(birthMetrics);
|
||||
|
||||
// Devices first: a device table must never outlive the node birth that voided it, whatever the
|
||||
// node's own rebuild does.
|
||||
var invalidated = Evict(scope => scope.DeviceId is not null && scope.IsUnder(groupId, edgeNodeId));
|
||||
|
||||
var result = TableFor(new SparkplugScope(groupId, edgeNodeId, null)).RebuildFromBirth(birthMetrics);
|
||||
return new NodeBirthOutcome(result, invalidated);
|
||||
}
|
||||
|
||||
/// <summary>Applies a DBIRTH: rebuilds exactly that device's catalog, wholesale.</summary>
|
||||
/// <param name="groupId">The Sparkplug group id.</param>
|
||||
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
||||
/// <param name="deviceId">The Sparkplug device id.</param>
|
||||
/// <param name="birthMetrics">The DBIRTH payload's metrics.</param>
|
||||
/// <returns>What the birth installed, and what it refused.</returns>
|
||||
public BirthApplyResult ApplyDeviceBirth(
|
||||
string groupId,
|
||||
string edgeNodeId,
|
||||
string deviceId,
|
||||
IEnumerable<SparkplugMetric> birthMetrics)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
ArgumentNullException.ThrowIfNull(birthMetrics);
|
||||
|
||||
return TableFor(new SparkplugScope(groupId, edgeNodeId, deviceId)).RebuildFromBirth(birthMetrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The scope's catalog, or <see langword="null"/> when no live birth has been seen for it —
|
||||
/// which is the consumer's data-before-birth signal.
|
||||
/// </summary>
|
||||
/// <param name="scope">The scope to look up.</param>
|
||||
/// <returns>The catalog, or <see langword="null"/>.</returns>
|
||||
public AliasTable? Find(SparkplugScope scope) => _scopes.TryGetValue(scope, out var table) ? table : null;
|
||||
|
||||
/// <summary>
|
||||
/// Applies an NDEATH: forgets the edge node and every device under it, returning what was
|
||||
/// forgotten so the consumer can emit STALE for those metrics.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The Sparkplug group id.</param>
|
||||
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
||||
/// <returns>The evicted scopes and their metrics; empty when nothing was live.</returns>
|
||||
public IReadOnlyList<SparkplugScopeMetrics> ForgetNode(string groupId, string edgeNodeId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
|
||||
|
||||
return Evict(scope => scope.IsUnder(groupId, edgeNodeId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a DDEATH: forgets exactly one device, returning its metrics for the STALE fan-out.
|
||||
/// The node's own catalog is untouched.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The Sparkplug group id.</param>
|
||||
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
||||
/// <param name="deviceId">The Sparkplug device id.</param>
|
||||
/// <param name="removed">The evicted scope and its metrics when this returns <see langword="true"/>.</param>
|
||||
/// <returns><see langword="true"/> when the device had a live birth to forget.</returns>
|
||||
public bool TryForgetDevice(
|
||||
string groupId,
|
||||
string edgeNodeId,
|
||||
string deviceId,
|
||||
out SparkplugScopeMetrics removed)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(groupId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(edgeNodeId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var scope = new SparkplugScope(groupId, edgeNodeId, deviceId);
|
||||
if (_scopes.TryRemove(scope, out var table))
|
||||
{
|
||||
removed = new SparkplugScopeMetrics(scope, table.Metrics);
|
||||
return true;
|
||||
}
|
||||
|
||||
removed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Forgets every scope — the disconnect/reinitialise reset.</summary>
|
||||
public void Clear() => _scopes.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// The scope's catalog, created empty on first use. Rebuilds happen <b>in place</b> on the
|
||||
/// returned instance, so a consumer holding it observes the newest birth rather than a detached
|
||||
/// older one.
|
||||
/// </summary>
|
||||
/// <param name="scope">The scope.</param>
|
||||
/// <returns>The scope's catalog.</returns>
|
||||
private AliasTable TableFor(SparkplugScope scope) => _scopes.GetOrAdd(scope, static _ => new AliasTable());
|
||||
|
||||
/// <summary>Removes every scope matching <paramref name="predicate"/>, capturing their metrics first.</summary>
|
||||
/// <param name="predicate">Which scopes to evict.</param>
|
||||
/// <returns>The evicted scopes and the metrics they held.</returns>
|
||||
private List<SparkplugScopeMetrics> Evict(Func<SparkplugScope, bool> predicate)
|
||||
{
|
||||
List<SparkplugScopeMetrics>? evicted = null;
|
||||
|
||||
// ConcurrentDictionary enumeration is weakly consistent, which is exactly right here: this runs
|
||||
// on the dispatcher thread and only needs to see the scopes that existed when the birth/death
|
||||
// arrived.
|
||||
foreach (var scope in _scopes.Keys)
|
||||
{
|
||||
if (!predicate(scope) || !_scopes.TryRemove(scope, out var table))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(evicted ??= []).Add(new SparkplugScopeMetrics(scope, table.Metrics));
|
||||
}
|
||||
|
||||
return evicted ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Sparkplug metric-namespace scope: an edge node, or one device under it. The identity every
|
||||
/// birth, death and alias resolution is keyed by.
|
||||
/// </summary>
|
||||
/// <param name="GroupId">The Sparkplug group id.</param>
|
||||
/// <param name="EdgeNodeId">The Sparkplug edge-node id.</param>
|
||||
/// <param name="DeviceId">The Sparkplug device id, or <see langword="null"/> for the node's own scope.</param>
|
||||
public readonly record struct SparkplugScope(string GroupId, string EdgeNodeId, string? DeviceId)
|
||||
{
|
||||
/// <summary>Whether this is a device scope (a DBIRTH's) rather than an edge node's own.</summary>
|
||||
public bool IsDevice => DeviceId is not null;
|
||||
|
||||
/// <summary>The owning edge node's scope — this instance itself when it is already node-scoped.</summary>
|
||||
public SparkplugScope NodeScope => DeviceId is null ? this : new SparkplugScope(GroupId, EdgeNodeId, null);
|
||||
|
||||
/// <summary>Whether this scope belongs to the given edge node (the node's own scope included).</summary>
|
||||
/// <param name="groupId">The Sparkplug group id.</param>
|
||||
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
|
||||
/// <returns><see langword="true"/> when both segments match ordinally.</returns>
|
||||
public bool IsUnder(string groupId, string edgeNodeId) =>
|
||||
string.Equals(GroupId, groupId, StringComparison.Ordinal)
|
||||
&& string.Equals(EdgeNodeId, edgeNodeId, StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() =>
|
||||
DeviceId is null ? $"{GroupId}/{EdgeNodeId}" : $"{GroupId}/{EdgeNodeId}/{DeviceId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scope's metrics, captured at the moment it was evicted — the STALE fan-out's input, taken
|
||||
/// before the eviction so it cannot race a re-birth of the same scope.
|
||||
/// </summary>
|
||||
/// <param name="Scope">The scope that was evicted.</param>
|
||||
/// <param name="Metrics">The metrics its last birth declared.</param>
|
||||
public readonly record struct SparkplugScopeMetrics(SparkplugScope Scope, IReadOnlyList<SparkplugMetricBinding> Metrics);
|
||||
|
||||
/// <summary>The result of an NBIRTH: the node's own apply, plus the devices it invalidated.</summary>
|
||||
/// <param name="Result">What the node's catalog rebuild installed and refused.</param>
|
||||
/// <param name="InvalidatedDevices">
|
||||
/// The device scopes this NBIRTH voided, with the metrics they held — the STALE fan-out's input
|
||||
/// until each device re-DBIRTHs.
|
||||
/// </param>
|
||||
public readonly record struct NodeBirthOutcome(
|
||||
BirthApplyResult Result,
|
||||
IReadOnlyList<SparkplugScopeMetrics> InvalidatedDevices);
|
||||
@@ -0,0 +1,146 @@
|
||||
using Google.Protobuf;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
/// <summary>
|
||||
/// The one seam through which a Sparkplug NCMD is published — <see cref="MqttConnection"/> is the
|
||||
/// production implementation once it grows a publish leg (Task 21); tests substitute a fake so the
|
||||
/// encode/topic/QoS/retain contract is exercisable without a broker. Deliberately narrower than
|
||||
/// "publish anything": a Sparkplug driver's only outbound traffic is NCMD/DCMD, so this is not a
|
||||
/// general MQTT client abstraction in waiting.
|
||||
/// </summary>
|
||||
public interface IMqttPublishTransport
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes one application message. Implementations must be bounded — a broker that accepts
|
||||
/// PUBLISH and never completes it (QoS > 0 awaiting PUBACK) must fail at a deadline, not
|
||||
/// hang; <see cref="RebirthRequester.RequestAsync"/> supplies one, but an implementation used
|
||||
/// directly by a future caller must not rely on that.
|
||||
/// </summary>
|
||||
/// <param name="topic">The concrete topic to publish on.</param>
|
||||
/// <param name="payload">The message body.</param>
|
||||
/// <param name="qos">Requested QoS, 0–2.</param>
|
||||
/// <param name="retain">The MQTT <c>retain</c> flag.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation.</param>
|
||||
Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and publishes a Sparkplug B rebirth request — an NCMD carrying the well-known
|
||||
/// <c>Node Control/Rebirth</c> boolean metric, addressed to one edge node's command topic. See
|
||||
/// Sparkplug B v3.0 spec §6/§7 and design doc §3.6.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><see cref="Build"/> is pure.</b> It does no I/O and touches no transport, so it is
|
||||
/// trivially unit-testable and safe to call from anywhere (the ingest state machine on a
|
||||
/// detected gap, the browser on an operator click) without pulling in a live connection. The
|
||||
/// bounded, transport-facing publish is the separate <see cref="RequestAsync"/> overload.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No <c>seq</c>.</b> Sparkplug's sequence-number stream (§6.4.1) belongs to the messages an
|
||||
/// edge node itself publishes (NBIRTH/NDATA/DBIRTH/DDATA/NDEATH/DDEATH) — it is how a host
|
||||
/// detects a gap in <i>that node's</i> outbound stream. An NCMD flows the other direction, host
|
||||
/// → node, and is not a member of the node's sequence at all: stamping one on here would not be
|
||||
/// "wrong per spec" so much as meaningless, and a strict edge-node implementation that validated
|
||||
/// it against its own counter would have grounds to reject the command outright.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>QoS 0, retain <see langword="false"/> — always.</b> NCMD is ordinary Sparkplug command
|
||||
/// traffic, published at the same QoS 0 the spec uses for NDATA/DDATA/NBIRTH/DBIRTH (only
|
||||
/// NDEATH — the broker-issued Will — and STATE use QoS 1). <see langword="retain"/> is the one
|
||||
/// that matters operationally: a retained NCMD would be replayed by the broker to the edge node
|
||||
/// on <i>every</i> future subscribe — including the node's own reconnect — driving it into a
|
||||
/// permanent rebirth loop from a single stale command. See the carry-forward landmine on
|
||||
/// <c>MqttDriverBrowser</c>/NDEATH-as-Will for the sibling failure shape this type must not
|
||||
/// introduce on the publish side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>Datatype</c> and both timestamps are set.</b> <c>Node Control/Rebirth</c> is Boolean;
|
||||
/// setting <see cref="Payload.Types.Metric.Datatype"/> explicitly (rather than leaving it for the
|
||||
/// node to infer) and stamping both the metric and payload <c>timestamp</c> matches what a
|
||||
/// well-behaved command payload carries per spec §6.4.13 and what the Task-25 simulator (and any
|
||||
/// real Cirrus-Link-derived edge node) expects to validate against. An edge node that treats a
|
||||
/// missing/ambiguous field as malformed and silently drops the command would otherwise leave the
|
||||
/// driver retrying forever with no visible error — the whole point of a rebirth request is to
|
||||
/// recover from exactly that kind of silent desync, so the request itself must not risk being
|
||||
/// the next cause of one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RebirthRequester
|
||||
{
|
||||
/// <summary>The well-known Sparkplug B node-control metric name that triggers a rebirth.</summary>
|
||||
public const string RebirthMetricName = "Node Control/Rebirth";
|
||||
|
||||
/// <summary>
|
||||
/// Default bounded deadline for <see cref="RequestAsync"/> when the caller does not supply one.
|
||||
/// Deliberately this type's own value rather than <c>MqttDriverOptions.ConnectTimeoutSeconds</c>
|
||||
/// — that knob already governs every MQTTnet connect/subscribe operation, and repurposing it
|
||||
/// would couple the rebirth-publish budget to the connect budget in a way neither side could
|
||||
/// tune independently (the same reasoning <c>MqttConnection.SubscribeAsync</c> documents for its
|
||||
/// own deadline).
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the wire bytes and target topic for a rebirth-request NCMD, without publishing it.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The Sparkplug group id.</param>
|
||||
/// <param name="edgeNodeId">The Sparkplug edge-node id to address the command to.</param>
|
||||
/// <returns>The NCMD topic (<c>spBv1.0/{groupId}/NCMD/{edgeNodeId}</c>) and its encoded payload.</returns>
|
||||
/// <exception cref="ArgumentException"><paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null or empty.</exception>
|
||||
public static (string Topic, byte[] Bytes) Build(string groupId, string edgeNodeId)
|
||||
{
|
||||
// Validates both ids and does the topic assembly — see the "don't hand-concatenate" remark on
|
||||
// SparkplugTopic.Format itself.
|
||||
var topic = SparkplugTopic.Format(groupId, SparkplugMessageType.NCMD, edgeNodeId);
|
||||
|
||||
var timestampMs = (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
var payload = new Payload { Timestamp = timestampMs };
|
||||
payload.Metrics.Add(new Payload.Types.Metric
|
||||
{
|
||||
Name = RebirthMetricName,
|
||||
Datatype = (uint)TahuDataType.Boolean,
|
||||
Timestamp = timestampMs,
|
||||
BooleanValue = true,
|
||||
});
|
||||
|
||||
return (topic, payload.ToByteArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and publishes a rebirth-request NCMD under a bounded deadline. QoS 0, retain
|
||||
/// <see langword="false"/> — see the type remarks for why both are fixed rather than
|
||||
/// configurable.
|
||||
/// </summary>
|
||||
/// <param name="transport">The publish seam — the live connection, or a test fake.</param>
|
||||
/// <param name="groupId">The Sparkplug group id.</param>
|
||||
/// <param name="edgeNodeId">The Sparkplug edge-node id to address the command to.</param>
|
||||
/// <param name="cancellationToken">Caller cancellation; linked with the publish deadline.</param>
|
||||
/// <param name="timeout">
|
||||
/// The publish deadline. Defaults to <see cref="DefaultRequestTimeout"/> when omitted or
|
||||
/// non-positive.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException"><paramref name="groupId"/>/<paramref name="edgeNodeId"/> is null or empty.</exception>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="transport"/> is null.</exception>
|
||||
public static async Task RequestAsync(
|
||||
IMqttPublishTransport transport,
|
||||
string groupId,
|
||||
string edgeNodeId,
|
||||
CancellationToken cancellationToken,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transport);
|
||||
|
||||
var (topic, bytes) = Build(groupId, edgeNodeId);
|
||||
|
||||
var deadlineSpan = timeout is { } t && t > TimeSpan.Zero ? t : DefaultRequestTimeout;
|
||||
using var deadline = new CancellationTokenSource(deadlineSpan);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
|
||||
|
||||
await transport.PublishAsync(topic, bytes, qos: 0, retain: false, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
/// <summary>
|
||||
/// Per-edge-node Sparkplug B stream-continuity state: the wrapping <c>seq</c> counter that says
|
||||
/// whether the driver has missed a message, and the <c>bdSeq</c> session token that ties an
|
||||
/// NDEATH to the NBIRTH it belongs to. Design doc §3.6 invariant #3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One instance per edge node — this object <i>is</i> the edge node's stream state.</b> It
|
||||
/// holds no key and no map. Sparkplug numbers messages per edge node (an edge node's devices
|
||||
/// share its counter, so a DDATA advances the same sequence an NDATA does), and the ingest
|
||||
/// state machine already keeps per-scope state for the alias table and birth cache; giving
|
||||
/// this type a second internal dictionary would mean two maps whose lifetimes must be kept in
|
||||
/// step by hand, and a tracker that outlived a removed edge node would go on answering
|
||||
/// questions about a stream nobody is reading.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This type detects; it does not react.</b> It never publishes, never requests a rebirth
|
||||
/// and never emits a quality change — the ingest state machine reads its verdicts and decides.
|
||||
/// The split matters because "was a message lost" is a fact about the wire, while "request a
|
||||
/// rebirth now" is a policy gated on <c>requestRebirthOnGap</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Thread-safe under a plain monitor.</b> Sparkplug messages arrive on MQTTnet's shared
|
||||
/// dispatcher thread while the reconnect path can call <see cref="Reset"/> from another, and
|
||||
/// every operation here is a read-modify-write of two coupled fields — the immutable-snapshot
|
||||
/// discipline <c>MqttSubscriptionManager</c> uses fits a set that is swapped wholesale, not a
|
||||
/// counter that is advanced. The critical sections are a handful of instructions each and
|
||||
/// nothing inside them can block, so the simple lock this repo uses elsewhere
|
||||
/// (<c>MqttDriver._hostLock</c>) is the right shape.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here throws, for any input.</b> Same reasoning as
|
||||
/// <see cref="SparkplugCodec"/>: this sits behind an unauthenticated firehose on the
|
||||
/// dispatcher thread, so a spec-violating publisher gets a verdict, never an exception that
|
||||
/// would stall delivery for every subscription on the connection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SequenceTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// The largest legal Sparkplug <c>seq</c>. The counter is a byte on the wire in spirit but a
|
||||
/// <c>uint64</c> in the schema, so the range check lives here — see <see cref="Accept"/>.
|
||||
/// </summary>
|
||||
public const ulong MaxSeq = 255;
|
||||
|
||||
private readonly object _gate = new();
|
||||
|
||||
/// <summary>The last credible sequence number, meaningful only when <see cref="_hasLastSeq"/>.</summary>
|
||||
private byte _lastSeq;
|
||||
|
||||
/// <summary>Whether any credible sequence number has been observed since the last reset.</summary>
|
||||
private bool _hasLastSeq;
|
||||
|
||||
/// <summary>Whether the current baseline was established by a birth rather than adopted mid-stream.</summary>
|
||||
private bool _birthSynchronized;
|
||||
|
||||
/// <summary>The current session's <c>bdSeq</c>, or null when no birth has supplied one.</summary>
|
||||
private ulong? _birthBdSeq;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the current sequence baseline came from a birth. <see langword="false"/> also after
|
||||
/// a first message that was <i>not</i> a birth — the driver joined mid-stream and holds no
|
||||
/// alias table, which is a different situation from a gap even though both call for a rebirth.
|
||||
/// </summary>
|
||||
public bool IsBirthSynchronized
|
||||
{
|
||||
get { lock (_gate) return _birthSynchronized; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last credible sequence number observed, or <see langword="null"/> when none has been —
|
||||
/// before the first message, or after <see cref="Reset"/>.
|
||||
/// </summary>
|
||||
public byte? LastSeq
|
||||
{
|
||||
get { lock (_gate) return _hasLastSeq ? _lastSeq : null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>bdSeq</c> of the most recent birth, or <see langword="null"/> when no birth has
|
||||
/// supplied one. This is the token <see cref="IsDeathForCurrentSession"/> matches against.
|
||||
/// </summary>
|
||||
public ulong? BirthBdSeq
|
||||
{
|
||||
get { lock (_gate) return _birthBdSeq; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an <b>NBIRTH</b>: restarts the sequence at the birth's <c>seq</c> and adopts its
|
||||
/// <c>bdSeq</c> as the current session token.
|
||||
/// </summary>
|
||||
/// <param name="seq">
|
||||
/// The birth payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
|
||||
/// <see langword="null"/> when the message carried none.
|
||||
/// </param>
|
||||
/// <param name="bdSeq">
|
||||
/// The birth's session token, read from its <c>bdSeq</c> metric with
|
||||
/// <see cref="TryReadBdSeq"/>; <see langword="null"/> when the birth carried none.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when the birth established a usable sequence baseline;
|
||||
/// <see langword="false"/> when its <c>seq</c> was absent or out of range.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>An NBIRTH is never a gap.</b> It restarts the sequence by definition, so it must not
|
||||
/// be run through <see cref="Accept"/> — doing so would flag a gap on the very message
|
||||
/// that just resynchronized everything, and answer a birth with a request for another one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A DBIRTH must NOT be offered here — it belongs to <see cref="Accept"/>.</b> Only the
|
||||
/// NBIRTH restarts the sequence; a DBIRTH carries the next number in the edge node's
|
||||
/// ongoing stream, and only the NBIRTH (with the NDEATH) carries <c>bdSeq</c> at all.
|
||||
/// Routing a DBIRTH here would do two wrong things at once: silently rebase the sequence
|
||||
/// mid-stream, and — because the DBIRTH has no <c>bdSeq</c> to pass — <b>wipe the node's
|
||||
/// session token to null</b>. A stale Last Will arriving afterwards would then find
|
||||
/// nothing to be compared against, fall through the fail-toward-stale rule in
|
||||
/// <see cref="IsDeathForCurrentSession"/>, and kill the live node this type exists to
|
||||
/// protect. The method is named for the node deliberately, so that call site reads wrong.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An in-range <c>seq</c> other than the spec-required 0 is adopted, not refused.</b>
|
||||
/// Refusing it would leave the tracker unsynchronized against a publisher whose stream is
|
||||
/// otherwise perfectly followable, and every message that followed would demand a fresh
|
||||
/// rebirth — the storm this type exists to prevent, arrived at by being strict.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The <c>bdSeq</c> is recorded even when the <c>seq</c> was unusable.</b> Session
|
||||
/// identity and position-in-stream are independent facts; discarding the tie because the
|
||||
/// sequence number was garbled would leave the next NDEATH unmatchable, which is the one
|
||||
/// thing that lets a stale will kill a live node.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool AcceptNodeBirth(ulong? seq, ulong? bdSeq = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_birthBdSeq = bdSeq;
|
||||
|
||||
if (seq is not { } value || value > MaxSeq)
|
||||
{
|
||||
_hasLastSeq = false;
|
||||
_birthSynchronized = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastSeq = (byte)value;
|
||||
_hasLastSeq = true;
|
||||
_birthSynchronized = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offers the next sequenced message's <c>seq</c> — <b>DBIRTH</b>, NDATA, DDATA or DDEATH —
|
||||
/// and reports whether the stream is still contiguous.
|
||||
/// </summary>
|
||||
/// <param name="seq">
|
||||
/// The payload's <c>seq</c> — <see cref="SparkplugPayload.Seq"/>, which is
|
||||
/// <see langword="null"/> when the message carried none.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when this <c>seq</c> is the one that follows the last;
|
||||
/// <see langword="false"/> when a message was missed, when the value is out of range, or when
|
||||
/// it is absent. A <see langword="false"/> is the caller's cue to request a rebirth.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The wrap is the whole point: next-expected is <c>(last + 1) & 0xFF</c>, so
|
||||
/// 255 → 0 is the sequence continuing, not a gap.</b> Getting the boundary wrong fails
|
||||
/// loudly in one direction and silently in the other. Read the wrap as a gap and every
|
||||
/// edge node is asked to rebirth once per 256 messages, so a busy node spends its life
|
||||
/// republishing births instead of publishing data. Miss a real gap and the driver serves
|
||||
/// values it knows are behind the device, at Good quality, indefinitely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A gap is reported once, then the tracker resynchronizes onto the observed value.</b>
|
||||
/// Holding the baseline at the pre-gap number would make every following message report a
|
||||
/// gap too, so a single lost message would become a permanent rebirth storm.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An out-of-range <c>seq</c> is refused, never masked into range.</b> The codec hands
|
||||
/// this over as a <see cref="ulong"/> precisely so a spec-violating publisher's value is
|
||||
/// not silently truncated into a plausible-looking one, and masking would finish the job
|
||||
/// it declined to do: with the baseline at 255, a <c>seq</c> of 256 masks to 0 — exactly
|
||||
/// the value the wrap expects next — and the bogus message would be accepted as
|
||||
/// contiguous. Refused values do not become the baseline either, so the next genuine
|
||||
/// message is still measured against the last credible one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The first message is not a gap</b> — there is nothing to measure it against. It is
|
||||
/// adopted as the baseline, and <see cref="IsBirthSynchronized"/> stays
|
||||
/// <see langword="false"/> to record that the driver joined mid-stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>NDEATH must not be offered here.</b> An NDEATH is the broker-published Last Will and
|
||||
/// carries no <c>seq</c> at all; feeding it in would report a gap on a message that never
|
||||
/// had a place in the sequence. It is tied to its birth by
|
||||
/// <see cref="IsDeathForCurrentSession"/> instead. A DDEATH is published by the edge node
|
||||
/// and <i>is</i> sequenced, so it does belong here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool Accept(ulong? seq)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
// Refused without becoming the baseline: see the out-of-range remarks above.
|
||||
if (seq is not { } value || value > MaxSeq)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var current = (byte)value;
|
||||
|
||||
if (!_hasLastSeq)
|
||||
{
|
||||
_lastSeq = current;
|
||||
_hasLastSeq = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
var expected = (byte)((_lastSeq + 1) & 0xFF);
|
||||
|
||||
// Resynchronize even on a gap — one lost message must not become a permanent storm.
|
||||
_lastSeq = current;
|
||||
|
||||
return current == expected;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether an NDEATH belongs to the session currently believed alive — the
|
||||
/// <c>bdSeq</c> tie of §3.6 invariant #3.
|
||||
/// </summary>
|
||||
/// <param name="deathBdSeq">
|
||||
/// The death payload's <c>bdSeq</c>, read with <see cref="TryReadBdSeq"/>;
|
||||
/// <see langword="null"/> when it carried none.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when the death applies and the node's metrics should go stale;
|
||||
/// <see langword="false"/> when it is a previous session's will and must be ignored.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What this prevents.</b> An edge node's connection drops, it reconnects and publishes
|
||||
/// a fresh NBIRTH — and only <i>then</i> does the broker notice the old connection died
|
||||
/// and deliver its retained Last Will. The NDEATH arrives after the NBIRTH, carrying the
|
||||
/// previous session's <c>bdSeq</c>. Without this comparison that stale will marks a live,
|
||||
/// freshly-born node dead and drives every one of its tags to Bad quality, with no further
|
||||
/// message coming to correct it until the node happens to rebirth again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Unknowns fail toward stale, deliberately.</b> A death arriving before any birth was
|
||||
/// seen, a death carrying no <c>bdSeq</c>, or a birth that carried none all resolve to
|
||||
/// <see langword="true"/>. The two error directions are not symmetric: acting on a death
|
||||
/// wrongly marks tags stale until the next birth restores them (§3.6 invariant #4, a
|
||||
/// self-correcting error an operator can see), while ignoring a real death leaves a dead
|
||||
/// node's last values flowing at Good quality forever. Only a <i>positive mismatch</i> —
|
||||
/// two known, different tokens — is evidence enough to discard a death.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Pure.</b> It answers a question and changes nothing, so the caller can ask it while
|
||||
/// logging or deciding. A death that <i>is</i> acted on needs no reset here: the node is
|
||||
/// offline, and the next birth calls <see cref="AcceptNodeBirth"/> which restarts the sequence
|
||||
/// anyway.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool IsDeathForCurrentSession(ulong? deathBdSeq)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_birthBdSeq is not { } birth || deathBdSeq is not { } death)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return birth == death;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the tracker to its virgin state: no sequence baseline, not birth-synchronized, no
|
||||
/// session token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reconnect seam (§3.6 invariant #5 — late join). After a dropped connection the driver
|
||||
/// has missed an unknown number of messages, so the baseline it was holding is no longer
|
||||
/// evidence of anything: carrying it across would let the stream look contiguous purely
|
||||
/// because the edge node happened to publish the right number of messages while the driver was
|
||||
/// away.
|
||||
/// </remarks>
|
||||
public void Reset()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastSeq = 0;
|
||||
_hasLastSeq = false;
|
||||
_birthSynchronized = false;
|
||||
_birthBdSeq = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the <c>bdSeq</c> session token out of a decoded NBIRTH or NDEATH payload.
|
||||
/// </summary>
|
||||
/// <param name="payload">The decoded payload; <see langword="null"/> and invalid are both handled.</param>
|
||||
/// <param name="bdSeq">The token on success; <c>0</c> otherwise.</param>
|
||||
/// <returns><see langword="true"/> when the payload carried a usable <c>bdSeq</c> metric.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><c>bdSeq</c> is not a top-level payload field</b> — unlike <c>seq</c> it rides as an
|
||||
/// ordinary metric named <c>bdSeq</c>, which is why reading it is a helper rather than a
|
||||
/// property. The name is matched exactly (ordinal): the spec fixes it, so a case variant
|
||||
/// is far more likely to be a different metric than a typo of this one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Signed values are reinterpreted before they are judged.</b> Sparkplug carries
|
||||
/// <c>Int64</c> as two's complement in an unsigned field, so a negative <c>bdSeq</c>
|
||||
/// reaches the projection as an enormous <see cref="ulong"/>. Read raw it would become a
|
||||
/// plausible-looking session token minted out of nonsense; run through
|
||||
/// <see cref="SparkplugCodec.ReinterpretSigned"/> it is visibly negative and refused.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The value is treated as an opaque token, not as a 0–255 counter.</b> Its only job
|
||||
/// here is equality against the birth's, and a range check could never make a match more
|
||||
/// or less correct — it could only discard the one piece of evidence tying a death to its
|
||||
/// birth. This is the opposite call from <c>seq</c>, whose range is load-bearing precisely
|
||||
/// because its arithmetic wraps.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool TryReadBdSeq(SparkplugPayload? payload, out ulong bdSeq)
|
||||
{
|
||||
bdSeq = 0;
|
||||
|
||||
if (payload is not { IsValid: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var metric in payload.Metrics)
|
||||
{
|
||||
if (!string.Equals(metric.Name, "bdSeq", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (metric.ValueKind != SparkplugValueKind.Scalar)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = metric.DataType is { } datatype
|
||||
? SparkplugCodec.ReinterpretSigned(metric.Value, datatype)
|
||||
: metric.Value;
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case ulong u:
|
||||
bdSeq = u;
|
||||
return true;
|
||||
case uint u:
|
||||
bdSeq = u;
|
||||
return true;
|
||||
case long l when l >= 0:
|
||||
bdSeq = (ulong)l;
|
||||
return true;
|
||||
case int i when i >= 0:
|
||||
bdSeq = (ulong)i;
|
||||
return true;
|
||||
case short s when s >= 0:
|
||||
bdSeq = (ulong)s;
|
||||
return true;
|
||||
case sbyte sb when sb >= 0:
|
||||
bdSeq = (ulong)sb;
|
||||
return true;
|
||||
default:
|
||||
// Negative, or a wire type no integer token can come from.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using Google.Protobuf;
|
||||
using Org.Eclipse.Tahu.Protobuf;
|
||||
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes Sparkplug-B wire bytes into <see cref="SparkplugPayload"/> — the driver-side projection
|
||||
/// the ingest state machine consumes. Decode only; the NCMD <i>encode</i> path lives in
|
||||
/// <c>RebirthRequester</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Nothing here throws, for any input.</b> This runs on MQTTnet's shared dispatcher thread,
|
||||
/// behind an unauthenticated firehose the plant's edge nodes publish into. An escaping
|
||||
/// exception would not degrade one tag — it would stall or kill delivery for every
|
||||
/// subscription on the connection. Garbage bytes, a truncated body, a zero-length payload, a
|
||||
/// valid protobuf of some other schema and a pathologically nested Template all resolve to a
|
||||
/// verdict: <see cref="TryDecode"/> returns <see langword="false"/> and
|
||||
/// <see cref="Decode"/> returns <see cref="SparkplugPayload.Invalid"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Zero-length input is invalid, not empty.</b> Protobuf would happily parse zero bytes as
|
||||
/// "a Payload with every field defaulted", but in Sparkplug an empty MQTT body is never a
|
||||
/// legitimate message — treating it as a well-formed payload carrying no metrics is how a
|
||||
/// truncated-to-nothing body gets mistaken for a real one. It is reported as invalid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Explicit presence is preserved, everywhere.</b> The vendored schema is proto2 precisely
|
||||
/// so absence and zero stay distinguishable, and every projection below reads
|
||||
/// <c>Has{Seq,Name,Alias,Datatype,Timestamp,IsNull}</c> rather than testing a value against its
|
||||
/// default. Two cases make this load-bearing rather than pedantic: a Sparkplug NBIRTH is
|
||||
/// REQUIRED to carry <c>seq = 0</c>, and every DATA metric after a birth carries an alias with
|
||||
/// <b>no</b> name and <b>no</b> datatype. A zero-check decoder reports "no sequence" for every
|
||||
/// birth and "" for every DATA metric name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Values are projected raw — nothing is coerced or reinterpreted here.</b> A metric's value
|
||||
/// arrives as the CLR type of the wire field that carried it (see
|
||||
/// <see cref="SparkplugMetric.Value"/>); coercion against the authored tag's declared
|
||||
/// <c>DriverDataType</c> is the consumer's job, and follows this driver's standing rule that a
|
||||
/// value which does not fit is refused rather than silently converted. The one wire-level
|
||||
/// translation this type does offer is <see cref="ReinterpretSigned"/>, because Sparkplug's
|
||||
/// two's-complement-in-an-unsigned-field encoding of signed integers is a property of the
|
||||
/// <i>wire</i>, not of the tag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>DataSet</c> and <c>Template</c> metrics are out of scope for v1.</b> They decode to
|
||||
/// <see cref="SparkplugValueKind.Unsupported"/> with a null value — deliberately visible, so a
|
||||
/// consumer can warn about a metric it cannot serve instead of silently treating it as one
|
||||
/// that was never published.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SparkplugCodec
|
||||
{
|
||||
/// <summary>Decodes Sparkplug-B wire bytes, reporting success rather than throwing.</summary>
|
||||
/// <param name="wire">The MQTT message body.</param>
|
||||
/// <param name="payload">
|
||||
/// The decoded payload on success; <see cref="SparkplugPayload.Invalid"/> otherwise. Never
|
||||
/// <see langword="null"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> when the bytes parsed as a Sparkplug-B <c>Payload</c>. Protobuf is a
|
||||
/// permissive, self-describing-only-by-convention format, so this means "parsed" rather than
|
||||
/// "was genuinely produced by a Sparkplug edge node" — unknown fields are preserved by
|
||||
/// Google.Protobuf and simply ignored here.
|
||||
/// </returns>
|
||||
public static bool TryDecode(ReadOnlySpan<byte> wire, out SparkplugPayload payload)
|
||||
{
|
||||
payload = SparkplugPayload.Invalid;
|
||||
|
||||
if (wire.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var proto = Payload.Parser.ParseFrom(wire);
|
||||
|
||||
var metrics = proto.Metrics.Count == 0
|
||||
? []
|
||||
: new SparkplugMetric[proto.Metrics.Count];
|
||||
|
||||
for (var i = 0; i < proto.Metrics.Count; i++)
|
||||
{
|
||||
metrics[i] = ProjectMetric(proto.Metrics[i]);
|
||||
}
|
||||
|
||||
payload = new SparkplugPayload(
|
||||
IsValid: true,
|
||||
Seq: proto.HasSeq ? proto.Seq : null,
|
||||
TimestampMs: proto.HasTimestamp ? proto.Timestamp : null,
|
||||
Metrics: metrics);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Deliberately broad, and deliberately spanning the projection as well as the parse.
|
||||
// InvalidProtocolBufferException covers malformed, truncated and over-nested input, but
|
||||
// this is the dispatcher-thread boundary and the projection walks an attacker-shaped
|
||||
// object graph: the contract is a verdict for EVERY input, so a guard that stopped at
|
||||
// the parse would be a contract with a hole in it. Narrowing the catch would trade the
|
||||
// guarantee for a taxonomy nobody downstream can act on.
|
||||
payload = SparkplugPayload.Invalid;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes Sparkplug-B wire bytes, returning <see cref="SparkplugPayload.Invalid"/> when they
|
||||
/// cannot be decoded.
|
||||
/// </summary>
|
||||
/// <param name="wire">The MQTT message body.</param>
|
||||
/// <returns>The decoded payload — never <see langword="null"/>.</returns>
|
||||
/// <remarks>
|
||||
/// The convenience shape. <b>Check <see cref="SparkplugPayload.IsValid"/>:</b> an undecodable
|
||||
/// body and a genuinely metric-less one both present as an empty
|
||||
/// <see cref="SparkplugPayload.Metrics"/>, and only the flag tells them apart. Prefer
|
||||
/// <see cref="TryDecode"/> where the verdict drives control flow.
|
||||
/// </remarks>
|
||||
public static SparkplugPayload Decode(ReadOnlySpan<byte> wire) =>
|
||||
TryDecode(wire, out var payload) ? payload : SparkplugPayload.Invalid;
|
||||
|
||||
/// <summary>
|
||||
/// Reinterprets a raw metric value as the signed integer Sparkplug encoded it as, when its
|
||||
/// datatype is a signed integer type; returns the value unchanged for every other datatype.
|
||||
/// </summary>
|
||||
/// <param name="value">
|
||||
/// A <see cref="SparkplugMetric.Value"/> — a <see cref="uint"/> for the 8/16/32-bit arms, a
|
||||
/// <see cref="ulong"/> for the 64-bit arm.
|
||||
/// </param>
|
||||
/// <param name="datatype">The metric's Sparkplug datatype, from its birth or its own field.</param>
|
||||
/// <returns>
|
||||
/// <see cref="sbyte"/> / <see cref="short"/> / <see cref="int"/> / <see cref="long"/> for the
|
||||
/// signed integer datatypes; <paramref name="value"/> untouched otherwise (including when it is
|
||||
/// <see langword="null"/> or not the wire type the datatype implies).
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Sparkplug carries <c>Int8</c>/<c>Int16</c>/<c>Int32</c> in the <b>unsigned</b>
|
||||
/// <c>int_value</c> field and <c>Int64</c> in the unsigned <c>long_value</c> field, as two's
|
||||
/// complement. Handing the raw field to a consumer publishes <c>4294967254</c> for a tag whose
|
||||
/// value is <c>-42</c> — a wrong value that looks entirely plausible, arrives with Good
|
||||
/// quality, and is invisible until someone reads a gauge. This is the single call that undoes
|
||||
/// it, and it is separate from the decode itself because a DATA metric carries no datatype of
|
||||
/// its own: only the consumer, holding the alias table built from the birth, knows which
|
||||
/// datatype applies.
|
||||
/// </remarks>
|
||||
public static object? ReinterpretSigned(object? value, TahuDataType datatype) => datatype switch
|
||||
{
|
||||
TahuDataType.Int8 when value is uint raw => unchecked((sbyte)raw),
|
||||
TahuDataType.Int16 when value is uint raw => unchecked((short)raw),
|
||||
TahuDataType.Int32 when value is uint raw => unchecked((int)raw),
|
||||
TahuDataType.Int64 when value is ulong raw => unchecked((long)raw),
|
||||
_ => value,
|
||||
};
|
||||
|
||||
/// <summary>Projects one generated metric onto the driver-side shape, presence intact.</summary>
|
||||
/// <param name="metric">The generated metric.</param>
|
||||
/// <returns>The projection.</returns>
|
||||
private static SparkplugMetric ProjectMetric(Payload.Types.Metric metric)
|
||||
{
|
||||
var (kind, value) = ProjectValue(metric);
|
||||
|
||||
return new SparkplugMetric(
|
||||
Name: metric.HasName ? metric.Name : null,
|
||||
Alias: metric.HasAlias ? metric.Alias : null,
|
||||
|
||||
// Cast, never filter: an index this build's enum does not define is a future Sparkplug
|
||||
// revision or a misbehaving publisher, and dropping it would deny the mapping layer the
|
||||
// only evidence it has.
|
||||
DataType: metric.HasDatatype ? (TahuDataType)metric.Datatype : null,
|
||||
TimestampMs: metric.HasTimestamp ? metric.Timestamp : null,
|
||||
ValueKind: kind,
|
||||
Value: value);
|
||||
}
|
||||
|
||||
/// <summary>Resolves a metric's value <c>oneof</c> to a kind plus a raw CLR value.</summary>
|
||||
/// <param name="metric">The generated metric.</param>
|
||||
/// <returns>The value kind and the projected value.</returns>
|
||||
/// <remarks>
|
||||
/// <c>is_null</c> wins over the <c>oneof</c>: the Sparkplug spec's whole reason for the field is
|
||||
/// that some datatypes have no spare sentinel, so a publisher that sets it means "null" even if
|
||||
/// a value field is also populated.
|
||||
/// </remarks>
|
||||
private static (SparkplugValueKind Kind, object? Value) ProjectValue(Payload.Types.Metric metric)
|
||||
{
|
||||
if (metric.HasIsNull && metric.IsNull)
|
||||
{
|
||||
return (SparkplugValueKind.Null, null);
|
||||
}
|
||||
|
||||
return metric.ValueCase switch
|
||||
{
|
||||
Payload.Types.Metric.ValueOneofCase.IntValue => (SparkplugValueKind.Scalar, metric.IntValue),
|
||||
Payload.Types.Metric.ValueOneofCase.LongValue => (SparkplugValueKind.Scalar, metric.LongValue),
|
||||
Payload.Types.Metric.ValueOneofCase.FloatValue => (SparkplugValueKind.Scalar, metric.FloatValue),
|
||||
Payload.Types.Metric.ValueOneofCase.DoubleValue => (SparkplugValueKind.Scalar, metric.DoubleValue),
|
||||
Payload.Types.Metric.ValueOneofCase.BooleanValue => (SparkplugValueKind.Scalar, metric.BooleanValue),
|
||||
Payload.Types.Metric.ValueOneofCase.StringValue => (SparkplugValueKind.Scalar, metric.StringValue),
|
||||
|
||||
// Copied out of the ByteString: the projection must outlive the parsed message.
|
||||
Payload.Types.Metric.ValueOneofCase.BytesValue =>
|
||||
(SparkplugValueKind.Scalar, metric.BytesValue.ToByteArray()),
|
||||
|
||||
// v1 scope boundary — decoded as a visible refusal, not as an exception or a silent drop.
|
||||
Payload.Types.Metric.ValueOneofCase.DatasetValue => (SparkplugValueKind.Unsupported, null),
|
||||
Payload.Types.Metric.ValueOneofCase.TemplateValue => (SparkplugValueKind.Unsupported, null),
|
||||
Payload.Types.Metric.ValueOneofCase.ExtensionValue => (SparkplugValueKind.Unsupported, null),
|
||||
|
||||
_ => (SparkplugValueKind.Absent, null),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A decoded Sparkplug-B payload: the driver-side projection of the generated <c>Payload</c>,
|
||||
/// carrying only what the ingest state machine consumes.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">
|
||||
/// <see langword="false"/> for a payload that could not be decoded. An invalid payload also has an
|
||||
/// empty <paramref name="Metrics"/> list, so <b>this flag is the only thing distinguishing an
|
||||
/// undecodable body from a legitimately metric-less one</b>.
|
||||
/// </param>
|
||||
/// <param name="Seq">
|
||||
/// The payload sequence number, or <see langword="null"/> when the message carried none. Kept as
|
||||
/// the wire's <see cref="ulong"/> rather than narrowed to a byte: the Sparkplug range is 0–255, but
|
||||
/// a publisher that violates it is reporting a fact the consumer should be able to see and reject,
|
||||
/// not one this layer should silently truncate into a plausible-looking sequence number.
|
||||
/// </param>
|
||||
/// <param name="TimestampMs">The payload timestamp in Sparkplug epoch milliseconds, or null when absent.</param>
|
||||
/// <param name="Metrics">The payload's metrics, in wire order. Never <see langword="null"/>.</param>
|
||||
public sealed record SparkplugPayload(
|
||||
bool IsValid,
|
||||
ulong? Seq,
|
||||
ulong? TimestampMs,
|
||||
IReadOnlyList<SparkplugMetric> Metrics)
|
||||
{
|
||||
/// <summary>The shared "could not decode this" result.</summary>
|
||||
public static readonly SparkplugPayload Invalid = new(false, null, null, []);
|
||||
}
|
||||
|
||||
/// <summary>One decoded Sparkplug metric.</summary>
|
||||
/// <param name="Name">
|
||||
/// The metric's stable name, or <see langword="null"/> when the message omitted it — which every
|
||||
/// real DATA metric after a birth does, carrying only <paramref name="Alias"/>. Distinguishing
|
||||
/// this from an empty string is the reason the vendored schema is proto2.
|
||||
/// </param>
|
||||
/// <param name="Alias">The per-birth alias, or <see langword="null"/> when the metric carried none.</param>
|
||||
/// <param name="DataType">
|
||||
/// The metric's declared datatype, or <see langword="null"/> when absent (again, the normal case
|
||||
/// for a DATA metric — its datatype comes from the birth). An index this build's enum does not
|
||||
/// define is preserved as an undefined enum value rather than dropped.
|
||||
/// </param>
|
||||
/// <param name="TimestampMs">
|
||||
/// The metric's own acquisition timestamp in Sparkplug epoch milliseconds, or
|
||||
/// <see langword="null"/> when it carried none — in which case the payload's timestamp applies.
|
||||
/// </param>
|
||||
/// <param name="ValueKind">
|
||||
/// What <paramref name="Value"/> means. Check this before reading the value: a null value is
|
||||
/// ambiguous between "explicitly null", "absent" and "a kind v1 does not support".
|
||||
/// </param>
|
||||
/// <param name="Value">
|
||||
/// The <b>raw</b> wire value, boxed: <see cref="uint"/> (<c>int_value</c>), <see cref="ulong"/>
|
||||
/// (<c>long_value</c>), <see cref="float"/>, <see cref="double"/>, <see cref="bool"/>,
|
||||
/// <see cref="string"/> or <see cref="byte"/><c>[]</c> — and <see langword="null"/> for every
|
||||
/// <paramref name="ValueKind"/> other than <see cref="SparkplugValueKind.Scalar"/>.
|
||||
/// <para>
|
||||
/// <b>Signed integers arrive as their unsigned two's-complement wire value</b> — a
|
||||
/// <c>DataType.Int32</c> metric holding -42 is a <see cref="uint"/> of 4294967254 here. Run it
|
||||
/// through <see cref="SparkplugCodec.ReinterpretSigned"/> with the metric's datatype (from the
|
||||
/// birth, for a DATA metric) before publishing it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Boxing is deliberate: the consumer coerces against the authored tag's declared
|
||||
/// <c>DriverDataType</c> and hands the result to a <c>DataValueSnapshot</c>, which boxes
|
||||
/// anyway, so a discriminated-union shape would buy an unboxing hop and cost every consumer a
|
||||
/// switch over a dozen arms.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public readonly record struct SparkplugMetric(
|
||||
string? Name,
|
||||
ulong? Alias,
|
||||
TahuDataType? DataType,
|
||||
ulong? TimestampMs,
|
||||
SparkplugValueKind ValueKind,
|
||||
object? Value);
|
||||
|
||||
/// <summary>What a decoded metric's <see cref="SparkplugMetric.Value"/> represents.</summary>
|
||||
/// <remarks>
|
||||
/// Three of the four members have a null <see cref="SparkplugMetric.Value"/> and mean entirely
|
||||
/// different things, which is exactly why the distinction is carried explicitly rather than left
|
||||
/// for a consumer to infer from a null.
|
||||
/// </remarks>
|
||||
public enum SparkplugValueKind
|
||||
{
|
||||
/// <summary>The metric's value <c>oneof</c> carried nothing at all.</summary>
|
||||
Absent = 0,
|
||||
|
||||
/// <summary>The metric set <c>is_null</c>: it exists, and its value is explicitly null.</summary>
|
||||
Null,
|
||||
|
||||
/// <summary><see cref="SparkplugMetric.Value"/> holds the raw wire value.</summary>
|
||||
Scalar,
|
||||
|
||||
/// <summary>
|
||||
/// A <c>DataSet</c>, <c>Template</c> or extension value — decoded and reported, but not
|
||||
/// supported in v1. The consumer should warn and skip the metric rather than treat it as
|
||||
/// missing.
|
||||
/// </summary>
|
||||
Unsupported,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user