Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77c8a3569 | |||
| 5184a2e107 | |||
| 88ce8df099 | |||
| 6a01358b9a | |||
| d32d89c340 | |||
| adce27e7fa | |||
| 09a401b881 | |||
| 53ede679c3 | |||
| e08855fb9d | |||
| 90bdaa4437 |
@@ -153,7 +153,71 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double,
|
||||
|
||||
### Change Detection
|
||||
|
||||
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys. The server's `DriverHost` consumes the signal and rebuilds the address space.
|
||||
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys.
|
||||
|
||||
**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27).
|
||||
`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is
|
||||
load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around
|
||||
the same driver object. A raise is recorded and rides the driver-health snapshot
|
||||
(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a
|
||||
**"re-browse" chip**.
|
||||
|
||||
⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw
|
||||
tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and
|
||||
commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact
|
||||
records.
|
||||
|
||||
Two gotchas if you touch this:
|
||||
|
||||
- `PublishHealthSnapshot` dedups on a health fingerprint. The rediscovery timestamp **must** stay in
|
||||
that tuple, or a raise on an otherwise-unchanged Healthy driver is swallowed and never reaches the
|
||||
operator. Pinned by `DriverInstanceActorRediscoverySignalTests`.
|
||||
- The shared test `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs on
|
||||
every call and the dedup never engages — a test built on it passes vacuously. The rediscovery tests
|
||||
use a stub with stable health for exactly this reason.
|
||||
|
||||
**#507 is closed as superseded, and the injection path is deleted.** Its retained code read
|
||||
`EquipmentNode.DriverInstanceId` and `EquipmentTags`, both *structurally empty* in v3
|
||||
(`AddressSpaceComposer.cs`, `DeploymentArtifact.cs`), so removing the guard would have changed a log
|
||||
line and injected nothing. Gone with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
|
||||
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
|
||||
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, the connect-time discovery loop
|
||||
(`StartDiscovery`/`RediscoverTick`/`DiscoveredNodesReady`/`TriggerRediscovery`), and the 18
|
||||
`DiscoveryInjectionDormantV3` tests — v2 characterization tests asserting an equipment-rooted graft
|
||||
(`EquipmentRootNodeId == "EQ-1"`) that v3 cannot produce. `ITagDiscovery` itself stays: the `/raw`
|
||||
browse picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through
|
||||
the actor.
|
||||
|
||||
**Tag-set drift detection (2026-07-28) — the second signal, and it is GATED.** `DriverInstanceActor`
|
||||
re-browses on a slow timer (`DefaultDriftCheckInterval`, 5 min) and compares what the remote offers
|
||||
against what an operator authored, raising the same re-browse prompt. This matters most for **AbCip and
|
||||
FOCAS**, which browse a live backend but implement no `IRediscoverable`, so a periodic compare is their
|
||||
only way to notice a PLC re-download.
|
||||
|
||||
⚠️ **It runs ONLY for a driver declaring `ITagDiscovery.SupportsOnlineDiscovery` AND reporting
|
||||
`AuthoredDiscoveryRefs` (nullable, default null = opt out).** Modbus, S7, MQTT, AbLegacy and Sql *echo
|
||||
authored config* from `DiscoverAsync` rather than browsing the device, so the diff for them is a
|
||||
tautology that would report "no drift" forever and read as coverage. A check that cannot fail is worse
|
||||
than no check. Note `AuthoredDiscoveryRefs` is **nullable, not empty-by-default** — an empty collection
|
||||
legitimately means "nothing authored, so everything discovered is new".
|
||||
|
||||
⚠️ **One direction is structurally undetectable for most drivers.** FOCAS, TwinCAT and AbCip re-emit
|
||||
their authored tags into the same `DiscoverAsync` stream as device-derived ones (so the browse picker
|
||||
shows existing tags), which means discovered ⊇ authored **always** and a *vanished* tag can never appear
|
||||
missing. Those drivers declare `DiscoveryStreamIncludesAuthoredTags => true`, and the detector then
|
||||
reports only what it can see and **says so** in the operator message rather than implying a clean bill of
|
||||
health. **MTConnect is currently the only driver where both directions are detectable** — its stream is
|
||||
built purely from the Agent's probe model.
|
||||
|
||||
Other properties worth knowing: a **failed browse is not drift** (an unreachable device would otherwise
|
||||
report every authored tag as vanished); a **truncated** capture is skipped for the same reason; an
|
||||
**unchanged** drift is reported once rather than re-stamping its timestamp every 5 min; and drift that
|
||||
resolves **clears** the prompt. The timer starts on Connected entry and is cancelled on every exit.
|
||||
|
||||
⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
|
||||
nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
|
||||
confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked
|
||||
as Gitea **#521**.
|
||||
|
||||
## mxaccessgw
|
||||
|
||||
@@ -269,7 +333,7 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
|
||||
|
||||
**Two corrections landed 2026-07-21 — both were total-outage or wrong-node defects, and both are worth knowing before touching this area.**
|
||||
|
||||
- **Downing is `auto-down`, not `keep-oldest`.** `Cluster:SplitBrainResolverStrategy` (default `auto-down`) selects the provider via `ServiceCollectionExtensions.BuildDowningHocon`; an unknown value fails host start. A two-node pair running the previous `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node**: Akka's `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs *itself* and exits. `down-if-alone` is a 3+-node feature. The accepted trade for `auto-down` is dual-active during a genuine network partition. **Its live gate is still open** — the pathology only appears 1-vs-1 and docker-dev is a single six-node mesh, so the crash-the-oldest drill is deferred to the per-cluster mesh work.
|
||||
- **Downing is `auto-down`, not `keep-oldest`.** `Cluster:SplitBrainResolverStrategy` (default `auto-down`) selects the provider via `ServiceCollectionExtensions.BuildDowningHocon`; an unknown value fails host start. A two-node pair running the previous `keep-oldest` + `down-if-alone` **could not survive a crash of the oldest node**: Akka's `KeepOldest.OldestDecision` only lets `down-if-alone` rescue a side holding ≥ 2 members, so the 1-vs-1 survivor downs *itself* and exits. `down-if-alone` is a 3+-node feature. The accepted trade for `auto-down` is dual-active during a genuine network partition. **Its live gate is CLOSED** — Phase 7 drill D4 (2026-07-24) ran the 1-vs-1 crash-the-oldest survival drill on the three-mesh docker-dev rig and every survivor stayed Up, never self-downing (`c50ebcf7`; `docs/plans/2026-07-24-mesh-phase7-failover-drills.md`).
|
||||
- **The Primary is the oldest Up `driver` member, not the role leader.** `RedundancyStateActor` previously used `ClusterState.RoleLeader("driver")` (lowest *address*), while `ClusterSingletonManager` places singletons on the *oldest*. They agree only on a freshly-formed cluster and diverge after any restart, so the Primary-gated data plane (writes, alarm acks, alerts emit, alarm-history drain) could enable on a node not hosting the singletons. `NodeRedundancyState.IsRoleLeaderForDriver` was renamed **`IsDriverPrimary`**.
|
||||
|
||||
**A third bootstrap gap closed 2026-07-22 — downing was never the whole story, and the fix changed twice.** Even under `auto-down`, a node cold-starting while its peer was dead **never came Up**: Akka runs `FirstSeedNodeProcess` — the only bootstrap path that can form a *new* cluster when no peer answers `InitJoin` — exclusively when `seed-nodes[0]` is the node’s own address; every other node runs `JoinSeedNodeProcess` and retries `InitJoin` forever. **`Cluster:SeedNodes` is therefore ORDERED: every node that is one of its own seeds lists ITSELF first, partner second** (docker-dev `central-2` was swapped; site nodes list only `central-1` and are legitimately not seeds). `AkkaClusterOptionsValidator` (`AddValidatedOptions`/`ValidateOnStart` in `AddOtOpcUaCluster`) enforces it at boot — **conditionally** (self must be entry 0 only *if* self is in the list, or every site node would refuse to start), matching on `PublicHostname`+`Port`, never the `0.0.0.0` bind address. The earlier fix — a `Cluster:SelfFormAfter` timer calling `Cluster.Join(SelfAddress)` — is **deleted**: it sat outside Akka’s join handshake, so it could not tell "no seed answered" from "a seed answered and the join is in flight", and it islanded a manually-failed-over node live before a TCP reachability guard patched that one shape. Pinned by `SelfFirstSeedBootstrapTests` (real in-process clusters, incl. a falsifiability control proving peer-first ordering never forms) + `AkkaClusterOptionsValidatorTests`. See `docs/Redundancy.md` §"Bootstrap: self-first seed ordering".
|
||||
@@ -380,7 +444,7 @@ Flip only the site nodes with `OTOPCUA_CONFIG_MODE=FetchAndCache` at `docker com
|
||||
|
||||
## Live telemetry transport (`Telemetry` / `TelemetryDial`)
|
||||
|
||||
Per-cluster mesh **Phase 5** (code-complete on `feat/mesh-phase5`, live gate pending) added a second
|
||||
Per-cluster mesh **Phase 5** (merged `35552a96`; live gate PASSED `f134935f`) added a second
|
||||
transport for the four live-observability channels the AdminUI's `/alerts`, `/script-log`, and
|
||||
`/hosts` panels feed on, selected by `Telemetry:Mode` (node/serve side) and `TelemetryDial:Mode`
|
||||
(central/dial side): `Dps` — the four existing DPS SignalR bridges, **the default**, today's
|
||||
@@ -490,7 +554,8 @@ unaffected (it keeps ConfigDb via its admin role). Consequences:
|
||||
- **Scripted-alarm Part 9 condition state moved to LocalDb.** `LocalDbAlarmConditionStateStore`
|
||||
(replicated `alarm_condition_state` table, pair-local like the Phase-2 alarm S&F buffer) replaces
|
||||
`EfAlarmConditionStateStore` on every driver-role node, fused central included. The DB-backed
|
||||
`ScriptedAlarmState` table is now unused (dropping it is a deferred follow-up, tracked as Task 9).
|
||||
`ScriptedAlarmState` table **was dropped** (Task 9, `3a590a0c`, migration
|
||||
`20260723183050_DropScriptedAlarmStateTable`); `EfAlarmConditionStateStore` is deleted.
|
||||
- **Central persists deploy acks.** `DriverHostActor` no longer writes `NodeDeploymentState` to SQL on
|
||||
a driver-only node — `ConfigPublishCoordinator.PersistNodeAck` (already fed by every ApplyAck) is
|
||||
the system of record.
|
||||
@@ -499,8 +564,8 @@ unaffected (it keeps ConfigDb via its admin role). Consequences:
|
||||
`OtOpcUaGroupRoleMapper` falls back to the `Security:Ldap:GroupToRole` appsettings baseline only —
|
||||
the same rows a driver node never received via config either, so this is not a regression.
|
||||
|
||||
Code-complete on `feat/mesh-phase4`; the table-drop (Task 9) and the live gate (Task 10) are still
|
||||
open. See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
|
||||
Merged to master (`26fad75c`); **live gate PASSED** (`1281aebf` — driver nodes run ConfigDb-free) and
|
||||
Task 9 landed (`3a590a0c`). See `docs/plans/2026-07-23-mesh-phase4-cut-driver-configdb.md` and
|
||||
`docs/plans/2026-07-22-per-cluster-mesh-program.md` §Phase 4.
|
||||
|
||||
## LDAP Authentication
|
||||
|
||||
@@ -9,78 +9,110 @@
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) — must FAIL at f6eaa267",
|
||||
"subject": "RED: connect-timeout regression tests T1+T3 (read/write degrade instead of throwing OCE) \u2014 must FAIL at f6eaa267",
|
||||
"status": "completed",
|
||||
"blockedBy": [0]
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4",
|
||||
"status": "completed",
|
||||
"blockedBy": [0, 1]
|
||||
"blockedBy": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)",
|
||||
"status": "completed",
|
||||
"blockedBy": [1, 2]
|
||||
"blockedBy": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)",
|
||||
"status": "completed",
|
||||
"blockedBy": [3]
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green",
|
||||
"status": "completed",
|
||||
"blockedBy": [4]
|
||||
"blockedBy": [
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) — must FAIL",
|
||||
"subject": "RED: framing-fault classification Theory T5 (TPKT/TPDU/WrongNumberOfBytes/PlcException-WrongNumberReceivedBytes reopen) \u2014 must FAIL",
|
||||
"status": "completed",
|
||||
"blockedBy": [0]
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)",
|
||||
"status": "completed",
|
||||
"blockedBy": [6]
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)",
|
||||
"status": "completed",
|
||||
"blockedBy": [7]
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "RED->GREEN: probe marks handle dead under the gate (T8 persistent probe fault + T9 probe timeout; ProbeLoopAsync inner catch restructure, STAB-15b)",
|
||||
"status": "completed",
|
||||
"blockedBy": [8]
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync <remarks> (STAB-8 S7 part, note-only)",
|
||||
"status": "completed",
|
||||
"blockedBy": [3]
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Env-gated live connect-timeout outage test (S7_TIMEOUT_OUTAGE_START_CMD/STOP_CMD blackhole design; skips cleanly offline)",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"note": "env-gated; runs in serial live pass (integration suite; serialized heavy pass). Test authored + committed; verified it SKIPs cleanly offline.",
|
||||
"blockedBy": [5]
|
||||
"blockedBy": [
|
||||
5
|
||||
],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-15 \u2014 S7 blackhole run via `docker pause` on the 10.100.0.35 s7_1500 fixture. Found + fixed a real read-leg wall-clock gap (PR #453). STATUS.md 'docker-dev live-/run pass'."
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Close-out: whole-suite sweep + update archreview STATUS.md and 05 prior-finding table (STAB-14/15 FIXED, STAB-8 seam noted)",
|
||||
"status": "completed",
|
||||
"note": "S7 unit 246/246 green; S7 CLI 48/49 (1 pre-existing unrelated comment-scan failure, see deviations); solution BUILD clean (0 errors). Whole-solution dotnet test NOT run per controller memory-constraint (integration suites deferred to serial heavy pass).",
|
||||
"blockedBy": [5, 7, 8, 9, 10, 11]
|
||||
"blockedBy": [
|
||||
5,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-13"
|
||||
"lastUpdated": "2026-07-27"
|
||||
}
|
||||
|
||||
@@ -1,25 +1,170 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-02-resilience-config-hardening-plan.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls — ResilienceConfigBrickTests, expect 2 failures on f6eaa267", "status": "completed", "blockedBy": [] },
|
||||
{ "id": 1, "subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": 2, "subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 3, "subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)", "status": "completed", "blockedBy": [0, 1] },
|
||||
{ "id": 4, "subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests", "status": "completed", "blockedBy": [2, 3] },
|
||||
{ "id": 5, "subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection", "status": "completed", "blockedBy": [1] },
|
||||
{ "id": 6, "subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc", "status": "completed", "blockedBy": [2] },
|
||||
{ "id": 7, "subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false — RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green", "status": "completed", "blockedBy": [6] },
|
||||
{ "id": 8, "subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests", "status": "completed", "blockedBy": [7] },
|
||||
{ "id": 9, "subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor", "status": "completed", "blockedBy": [7, 5] },
|
||||
{ "id": 10, "subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)", "status": "completed", "blockedBy": [2, 6] },
|
||||
{ "id": 11, "subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests", "status": "completed", "blockedBy": [5, 10] },
|
||||
{ "id": 12, "subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard", "status": "completed", "blockedBy": [10, 11] },
|
||||
{ "id": 13, "subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text", "status": "completed", "blockedBy": [11] },
|
||||
{ "id": 14, "subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green", "status": "completed", "blockedBy": [13] },
|
||||
{ "id": 15, "subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)", "status": "deferred-live", "note": "docker-dev :9200 live-/run; serial live pass", "blockedBy": [14] },
|
||||
{ "id": 16, "subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration", "status": "completed", "blockedBy": [3] },
|
||||
{ "id": 17, "subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)", "status": "completed", "blockedBy": [16] },
|
||||
{ "id": 18, "subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md", "status": "deferred-live", "note": "integration/full sweep; serialized heavy pass", "blockedBy": [4, 8, 9, 12, 15, 17] }
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls \u2014 ResilienceConfigBrickTests, expect 2 failures on f6eaa267",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "S-6 builder: belt-and-braces clamp in Build so pipeline construction can never throw ValidationException + Build_NeverThrows_ForHostileDirectOptions (turns task 0 green)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "S-6 production-wiring guard: DriverCapabilityInvokerFactory.Create with hostile JSON yields a working invoker + logged clamp diagnostic; re-run ResilienceInvokerFactoryRegistrationTests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "S-6 AdminUI: ResilienceFormModel.Validate() range messages (via ResiliencePolicyRanges through the transitive Core.Abstractions ref) + warning block in DriverResilienceSection",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "S-8 parser layer: force Write/AlarmAcknowledge retryCount overrides to 0 with diagnostic (spec invariant, honest dead-knob surfacing) + tests + GetTierDefaults doc",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "S-8 dispatch layer: flip HandleWriteAsync to isIdempotent:false \u2014 RED the DriverInstanceActorResilienceWiringTests expectation first, then flip; full Runtime suite green",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "S-8/P-4 invoker: cache the no-retry options snapshot once per invoker + add RecordCallStart/Complete tracker accounting to the non-idempotent arm + 2 CapabilityInvokerTests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "S-8 docs + form: fix WriteIdempotentAttribute's false 'reads via reflection' claim; note the non-idempotent production default; disable Write/Ack retry cells in the razor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "U-6 delete (Core): remove BulkheadMaxConcurrent/MaxQueue from DriverResilienceOptions + parser shape/merge/doc-example; add BulkheadKeys_InStoredJson_AreIgnored (migration-free contract)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "U-6 delete (AdminUI): strip bulkhead fields from ResilienceFormModel + the two razor inputs; update ResilienceFormModelTests",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "U-6 doc sweep (seam xmldocs, OTOPCUA0001 message + analyzer tests, operator docs; leave migrations/WedgeDetector/historical plans) + Options_properties_are_exactly_the_pipeline_wired_set knob-inertness guard",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
10,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "C-7 RED tests: unknown top-level key / unknown capability / unknown per-policy field survive round-trip; malformed JSON sets ParseFailed and ToJson returns the original text",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "C-7 implement: JsonObject-bag FromJson/ToJson (TagConfigJson idiom), ParseFailed + RawStoredJson, blank->null contract preserved, parser-interop test green",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "C-7 razor: ParseFailed warning banner + input lockout + explicit discard button; raw pane shows the stored ResilienceConfig; live-/run on docker-dev :9200 (rebuild BOTH centrals; SQL-mangle + unknown-key survival checks; also drive task 5 warnings and task 9 disabled cells)",
|
||||
"status": "completed",
|
||||
"note": "docker-dev :9200 live-/run; serial live pass",
|
||||
"blockedBy": [
|
||||
14
|
||||
],
|
||||
"lastUpdated": "2026-07-12"
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 range-validation banner verified on the docker-dev Modbus driver's Resilience overrides (`timeoutSeconds:0`). STATUS.md 'docker-dev live-/run pass'."
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "S-7 builder: add options-generation to PipelineKey + GetOrCreate(optionsGeneration=0) + thread through CapabilityInvoker; StaleGeneration_ReCache_IsNotServed_ToNewGeneration",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "S-7 factory: Interlocked per-Create generation stamp + Respawn_interleaved_with_old_invoker_call_still_applies_new_options wiring proof; update Invalidate comment (cleanup, not correctness)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Wrap-up: whole-solution build (0 warnings, analyzer silent) + full dotnet test gate; record the pass + the two report anchor drifts in archreview/plans/STATUS.md",
|
||||
"status": "completed",
|
||||
"note": "integration/full sweep; serialized heavy pass",
|
||||
"blockedBy": [
|
||||
4,
|
||||
8,
|
||||
9,
|
||||
12,
|
||||
15,
|
||||
17
|
||||
],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 S-8 verified: Write and AlarmAcknowledge RETRIES cells disabled ('never'). STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-27"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"tasks": [
|
||||
{
|
||||
"id": "R2-03-T1",
|
||||
"subject": "S13 stale-Good repro test at host level (RED — must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
|
||||
"subject": "S13 stale-Good repro test at host level (RED \u2014 must fail on f6eaa267): fail-after-success evaluator, publish probe sees Good then expects Bad AttributeValueUpdate",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
{
|
||||
"id": "R2-03-T9",
|
||||
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes — identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
|
||||
"subject": "P7 wiring-guard rewrite (RED): ApplyVirtualTags clears the compile cache only when the expression set changes \u2014 identical redeploy must NOT clear; expression edit must; shared-expression removal must not",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"R2-03-T3"
|
||||
@@ -86,7 +86,8 @@
|
||||
"R2-03-T8",
|
||||
"R2-03-T10"
|
||||
],
|
||||
"note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)"
|
||||
"note": "integration/full sweep; serialized heavy pass (build clean, Runtime.Tests 372/372, RoslynVirtualTagEvaluatorTests 20/20 verified locally; whole-solution/cross-suite sweep deferred per controller memory constraint)",
|
||||
"openNote": "STILL OPEN \u2014 live-blocked on a data-less rig: every materialised node reads Bad_WaitingForInitialData because no driver data flows, so a VT cannot be staged Good->Bad. Needs a reachable driver fixture feeding the VT. Unit-verified meanwhile."
|
||||
},
|
||||
{
|
||||
"id": "R2-03-T12",
|
||||
@@ -95,8 +96,9 @@
|
||||
"blockedBy": [
|
||||
"R2-03-T11"
|
||||
],
|
||||
"note": "docker-dev live-/run; serial live pass"
|
||||
"note": "docker-dev live-/run; serial live pass",
|
||||
"openNote": "STILL OPEN \u2014 same blocker as T11. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-13"
|
||||
"lastUpdated": "2026-07-27"
|
||||
}
|
||||
|
||||
@@ -1,22 +1,146 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] },
|
||||
{ "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T6", "subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T8", "subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] },
|
||||
{ "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T13", "subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T14", "subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)", "status": "completed", "blockedBy": ["T12", "T13"], "note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint." },
|
||||
{ "id": "T15", "subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)", "status": "deferred-live", "blockedBy": ["T14"], "note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate — controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin)." },
|
||||
{ "id": "T16", "subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)", "status": "completed", "blockedBy": ["T15"] }
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Gate UNS pages with ConfigEditor (GlobalUns, EquipmentPage)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "Gate cluster editors with ConfigEditor (NewCluster, ClusterEdit, NodeEdit, NamespaceEdit, AclEdit)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"subject": "Gate driver pages + routers with ConfigEditor (8 driver pages, DriverTypePicker, DriverEditRouter)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T3",
|
||||
"T6",
|
||||
"T7",
|
||||
"T8",
|
||||
"T9",
|
||||
"T10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
"subject": "Docs: CLAUDE.md ScriptAnalysis gate sentence, docs/security.md, STATUS.md R2-05 row",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T14",
|
||||
"subject": "Full-solution test sweep (dotnet test ZB.MOM.WW.OtOpcUa.slnx)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T12",
|
||||
"T13"
|
||||
],
|
||||
"note": "Impacted unit suites authoritative + green: AdminUI.Tests 517/517, Security.Tests 80/80. One whole-solution sweep ran BEFORE the controller's no-whole-solution/no-IntegrationTests constraint arrived; all failures are pre-existing/env and outside the R2-05 diff (Core.Abstractions.Tests InterfaceIndependence flags pre-existing Historian.* namespace types; Runtime historian retry timing; OpcUaClient.Browser needs a live OPC endpoint; AbCip/OpcUaServer/Host IntegrationTests need Docker fixtures). Will NOT be re-run per constraint."
|
||||
},
|
||||
{
|
||||
"id": "T15",
|
||||
"subject": "Live /run positive pass on docker-dev :9200 (rebuild BOTH centrals; auto-admin drives all page tiers; negative proof stays in T2/T3)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T14"
|
||||
],
|
||||
"note": "docker-dev live authz /run; serial live pass. Heavy integration/Playwright-dependent gate \u2014 controller runs serialized. docker-dev DisableLogin=true auto-authenticates as full-access admin, so role-deny differentiation is NOT observable there; the negative proof is CI-side (T2 AdminUiPoliciesTests + T3 PageAuthorizationGuardTests, both green). This pass only regression-checks over-gating (a typo'd policy name would render the deny slot even for the auto-admin).",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 (partial by design) \u2014 every config/read page renders under the all-roles multi-role-test session, so named-policy gating does not over-gate a legitimate admin. The DENY direction is unobservable on docker-dev (DisableLogin=true), as documented."
|
||||
},
|
||||
{
|
||||
"id": "T16",
|
||||
"subject": "Merge + bookkeeping (flip 04/C-1 row, record Reservations/ClusterRedundancy corrections)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T15"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-06-serverhistorian-failfast-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
|
||||
"subject": "RED: misconfig repro test \u2014 HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
|
||||
"subject": "GREEN: adapter guard \u2014 Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)",
|
||||
"subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType \u2014 0.2.0 proto3-optional presence witness; pin, no RED phase)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
@@ -89,12 +89,13 @@
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T9",
|
||||
"T11"
|
||||
],
|
||||
"note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker → real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 → gateway EnsureTags threw ArgumentOutOfRangeException → ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float→Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException — Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean→Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md."
|
||||
"note": "DONE 2026-07-13 on VPN vs a real 0.2.0 gateway (deployed locally in Docker \u2192 real AVEVA historian on wonder-sql-vd03 + Runtime SQL). 5/6 green. Found+FIXED a real OtOpcUa bug (PR #439, master 666908d7): GatewayTagProvisioner sent StorageRateMs=0 \u2192 gateway EnsureTags threw ArgumentOutOfRangeException \u2192 ALL historized-tag provisioning silently failed; stamp 1000ms + regression test. U-7 retype policy ANSWERED: EnsureTags is a real upsert; in-place retype between SUPPORTED types is ACCEPTED (Float\u2192Double=success); but Int1 (0.2.0 Boolean target) is NOT creatable on this histsdk (ProtocolEvidenceMissingException \u2014 Int1-only; Int2/UInt2/Int4/UInt4/Float/Double all provision), so a pre-bump Float Boolean asked to become Int1 FAILS the provision (not silently retyped/data-lost). The 1 red test (Boolean\u2192Int1) is a gateway histsdk type-support gap, not OtOpcUa. Deployment fixes: FQDN host + libgssapi-krb5-2/gss-ntlmssp in the gateway image + wonderapp SQL login for Runtime. Full record in STATUS.md.",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 (6/6) \u2014 vs. the local 0.2.0 gateway + real historian over VPN. STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-07-surgical-pure-adds-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "T1",
|
||||
@@ -45,20 +45,22 @@
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "Phase 1: over-the-wire SubscriptionSurvivalTests \u2014 subscribe, pure-add, item survives + new node browsable",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4b"
|
||||
],
|
||||
"note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client)."
|
||||
"note": "authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests suite leaks ~16GB/run per the memory constraint. MUST run in the serial heavy integration pass (single-test, boots one in-process server + client).",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 central-1 boot logged PureAdd (added=91, rebuild=False)."
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Phase 1: MANDATORY live-/run gate on docker-dev (rebuild BOTH centrals; Client.CLI subscribe survives +1-tag deploy; kind=PureAdd rebuild=False) \u2014 Phase 1 shippable boundary",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5"
|
||||
],
|
||||
"note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good)."
|
||||
"note": "docker-dev live /run surgical-path; serial heavy pass \u2014 F10b inertness risk, MUST run (subscribe survives +1-tag deploy; central log kind=PureAdd, rebuild=False; new node browsable/Good).",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 deleting a UNS virtual tag logged PureRemove (removed=1, rebuild=False) and the node vanished from the address space."
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
@@ -104,11 +106,12 @@
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "Phase 2: remove-side subscription-survival integration test + live-/run remove gate (kind=PureRemove rebuild=False) \u2014 Phase 2 shippable boundary",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
],
|
||||
"note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk."
|
||||
"note": "remove-side SubscriptionSurvivalTests authored + compiles (dotnet build green); NOT run \u2014 *.IntegrationTests memory constraint. Live docker-dev pure-remove gate (survivor subscription uninterrupted; central log kind=PureRemove, rebuild=False; removed node reads BadNodeIdUnknown) MUST run in the serial heavy pass \u2014 F10b inertness risk.",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 surgical add AND remove both execute in prod with no full rebuild; the remove path's 3 new sink members are wired, not inert."
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
@@ -121,11 +124,12 @@
|
||||
{
|
||||
"id": "T14",
|
||||
"subject": "Phase 3: mixed-deploy integration test + live-/run mixed gate + STATUS.md/P1 close-out \u2014 plan complete",
|
||||
"status": "deferred-live",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T13"
|
||||
],
|
||||
"note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk."
|
||||
"note": "mixed-deploy SubscriptionSurvivalTests authored + compiles (dotnet build green); docs close-out DONE (STATUS.md + 03/P1 marked REMEDIATED + plan deviations). Live docker-dev mixed gate (one deploy +1/-1; kind=AddRemoveMix, rebuild=False; survivor uninterrupted) MUST run in the serial heavy pass \u2014 F10b inertness risk.",
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T5/T6/T12. STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,94 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-10-resilience-observability-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "completed", "blockedBy": ["T1"] },
|
||||
{ "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "completed", "blockedBy": ["T1", "T4"] },
|
||||
{ "id": "T6", "subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)", "status": "completed", "blockedBy": ["T5"] },
|
||||
{ "id": "T7", "subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)", "status": "completed", "blockedBy": ["T4"] },
|
||||
{ "id": "T8", "subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges", "status": "completed", "blockedBy": ["T7"] },
|
||||
{ "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "completed", "blockedBy": ["T7"] },
|
||||
{ "id": "T10", "subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim", "status": "deferred-live", "blockedBy": ["T6", "T8", "T9"] },
|
||||
{ "id": "T11", "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", "status": "completed", "blockedBy": ["T10"] }
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1",
|
||||
"T4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Host DI: register the publisher in AddOtOpcUaDriverFactories + ResilienceStatusReaderRegistrationTests wiring guard (tracker HAS a production reader)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "AdminUI: IDriverResilienceStatusStore + InMemory impl + AddOtOpcUaDriverStatusServices registration (TDD mirroring DriverStatusSnapshotStoreTests)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"subject": "AdminUI: DriverResilienceStatusBridge DPS actor + spawn in WithOtOpcUaSignalRBridges",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit \u2014 verified by T10)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"subject": "Live-/run gate on docker-dev: rebuild BOTH centrals, S7 dead-endpoint breaker-open, both-replica chips, recovery clear, staleness dim",
|
||||
"status": "deferred-live",
|
||||
"blockedBy": [
|
||||
"T6",
|
||||
"T8",
|
||||
"T9"
|
||||
],
|
||||
"openNote": "PARTIAL \u2014 the resilience PIPELINE is live-verified (Polly CircuitBreaker -> Retry -> Timeout -> CapabilityInvoker in central-1 stack traces), but breaker-OPEN state was never forced: R2-09's ConnectionBackoff throttles retries so failures don't accumulate fast enough to open the Polly breaker. The reliable trigger is a rapid idempotent write to a dead endpoint. Confirmed still open 2026-07-27 (deferment.md \u00a78.5)."
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T10"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,30 +1,213 @@
|
||||
{
|
||||
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
|
||||
"lastUpdated": "2026-07-12",
|
||||
"lastUpdated": "2026-07-27",
|
||||
"tasks": [
|
||||
{ "id": "T1", "subject": "Golden TagConfig corpus + compose→encode→decode parity characterization test (Runtime.Tests, green on unmodified tree)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T2", "subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] },
|
||||
{ "id": "T6", "subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost → DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers", "status": "completed", "blockedBy": ["T4", "T5"] },
|
||||
{ "id": "T7", "subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent", "status": "completed", "blockedBy": ["T5"] },
|
||||
{ "id": "T8", "subject": "DraftValidator swap: Configuration→Commons ProjectReference; ExtractTagConfigFullName → TagConfigIntent.ExplicitFullName (null-on-absent preserved)", "status": "completed", "blockedBy": ["T2", "T3"] },
|
||||
{ "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] },
|
||||
{ "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] },
|
||||
{ "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T12", "subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T13", "subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T14", "subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T17", "subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)", "status": "completed", "blockedBy": ["T11"] },
|
||||
{ "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] },
|
||||
{ "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] },
|
||||
{ "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] },
|
||||
{ "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] },
|
||||
{ "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] },
|
||||
{ "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "completed", "blockedBy": ["T17", "T21"] },
|
||||
{ "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "deferred-live", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] }
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "Golden TagConfig corpus + compose\u2192encode\u2192decode parity characterization test (Runtime.Tests, green on unmodified tree)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1",
|
||||
"T3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Device-host re-home: composer TryExtractDeviceHost/NormalizeDeviceHost \u2192 DeviceConfigIntent; update DriverHostActor + DeploymentArtifact callers",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "DeploymentArtifact swap: delete 4 private mirrors, parse once per tag element via TagConfigIntent",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"subject": "DraftValidator swap: Configuration\u2192Commons ProjectReference; ExtractTagConfigFullName \u2192 TagConfigIntent.ExplicitFullName (null-on-absent preserved)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T2",
|
||||
"T3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T2",
|
||||
"T3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T3",
|
||||
"T5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "Modbus equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
"subject": "S7 equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings (+ amend Contracts banner)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T14",
|
||||
"subject": "AbCip equipment-tag parser: freeze test, shared readers (writable already honoured), Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T15",
|
||||
"subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T16",
|
||||
"subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T17",
|
||||
"subject": "FOCAS equipment-tag parser: force Writable:false (05/UNDER-1 correction), shared readers, Inspect warnings (+ amend Contracts banner)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T18",
|
||||
"subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T17"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T19",
|
||||
"subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T20",
|
||||
"subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T12",
|
||||
"T13",
|
||||
"T14",
|
||||
"T15",
|
||||
"T16",
|
||||
"T17"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T21",
|
||||
"subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T22",
|
||||
"subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)",
|
||||
"status": "completed",
|
||||
"blockedBy": [],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 the typed Modbus tag editor shows the Writable toggle and saving persists `writable: yes`."
|
||||
},
|
||||
{
|
||||
"id": "T23",
|
||||
"subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T17",
|
||||
"T21"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T24",
|
||||
"subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T5",
|
||||
"T6",
|
||||
"T7",
|
||||
"T8",
|
||||
"T9",
|
||||
"T10",
|
||||
"T12",
|
||||
"T13",
|
||||
"T14",
|
||||
"T15",
|
||||
"T16",
|
||||
"T17",
|
||||
"T18",
|
||||
"T19",
|
||||
"T21",
|
||||
"T22",
|
||||
"T23"
|
||||
],
|
||||
"closureNote": "LIVE GATE CLOSED 2026-07-13 \u2014 see T22. STATUS.md 'docker-dev live-/run pass'."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+667
@@ -0,0 +1,667 @@
|
||||
# Deferment register — new drivers, open issues, deferred work
|
||||
|
||||
> **Scope.** A source-verified inventory of what is unfinished in this repo: the status of the
|
||||
> recently-added drivers, every open tracker issue, in-code deferrals, open live gates, and the
|
||||
> documentation that misstates any of it.
|
||||
>
|
||||
> **Method.** Five parallel agents swept the tree independently; every claim below was checked
|
||||
> against **source** (`src/`, `tests/`, `git log`), not against documentation. Where a doc and the
|
||||
> code disagreed, the code won and the doc is listed in §7. Findings that could not be verified
|
||||
> without hardware or a live rig are marked **CANNOT VERIFY** rather than asserted.
|
||||
>
|
||||
> **Baseline.** `master` @ `90bdaa44`, 2026-07-27. All local branches are merged into master; the
|
||||
> only unmerged remotes are three abandoned branches (`origin/auto/driver-gaps` 184 commits behind
|
||||
> since 2026-04-26, `origin/auto/driver-gaps-stash`, `origin/refactor/galaxy-mxgateway-client-rename`).
|
||||
|
||||
---
|
||||
|
||||
## 0. The short version
|
||||
|
||||
The driver runtime is in good shape — **no new driver contains a stub, a `NotImplementedException`,
|
||||
or an unregistered factory.** What is unfinished clusters into four groups, in descending order of
|
||||
how much they should worry you:
|
||||
|
||||
| # | Theme | Why it matters |
|
||||
|---|---|---|
|
||||
| **1** | **Three subsystems are fully authored, persisted, and shipped — and never executed.** Node ACLs, `IRediscoverable`/`IHostConnectivityProbe`, and `DriverTypeRegistry`. | An operator can author a rule, save it, deploy it green, and have it do **nothing**. §3 |
|
||||
| **2** | **AdminUI authoring-dispatch gaps.** Calculation is offered in the driver picker but has no config form; three drivers have no device form; CSV + browse-commit dispatch maps miss the new drivers. | The "registered but unauthorable" class that has now bitten twice. §1.2 |
|
||||
| **3** | **17 open issues, all confirmed still open**, several broader than filed. | §2 |
|
||||
| **4** | **Bookkeeping drift.** 8 `.tasks.json` files show ~140 "pending" tasks for work that shipped; 5 CLAUDE.md status claims are stale. | Someone follows the tracker and rebuilds a shipped driver. §6, §7 |
|
||||
|
||||
Nothing here is a data-loss or outage defect. The sharpest edge is §3 — silent non-enforcement.
|
||||
|
||||
---
|
||||
|
||||
## 1. New drivers — source-verified status
|
||||
|
||||
Cross-cutting facts, verified once: all 12 driver types register in one place
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`); all 12 register a
|
||||
probe (`:119-130`) via `AddOtOpcUaDriverProbes()`, called from **both** the driver path (`:93`) and
|
||||
the admin path — **no probe is admin-node-missing**. `DriverTypeNames`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`) is guarded bidirectionally by
|
||||
reflection in `tests/Core/…Core.Abstractions.Tests/DriverTypeNamesGuardTests.cs`.
|
||||
|
||||
### 1.1 Status table
|
||||
|
||||
| Driver | Verdict | Merged | Capabilities | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | No `IWritable` **by design** (`:50`). No device form. |
|
||||
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) | Cleanest of the five: picker + driver form + device form + tag editor all present. 8 open follow-up issues. |
|
||||
| **Sql** | Complete-with-gaps | `4ad54037`, follow-ups `28c28667` | `IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe` (`SqlDriver.cs:28-29`) | No device form; no browse-commit branch; stale comments. |
|
||||
| **Modbus RTU-over-TCP** | **Complete** | `0f38f486` (#495) | transport mode inside Modbus (`ModbusTransportMode.cs`, `ModbusRtuOverTcpTransport.cs`) | Parses transport **by name** (`ModbusDriverFactoryExtensions.cs:77-78`) — dodges the systemic numeric-enum authoring bug. |
|
||||
| **Calculation** | **Partial (authoring gap)** | pre-existing | `IDriver, IDependencyConsumer, ISubscribable, IReadable` (`CalculationDriver.cs:38`) | **Registered + offered in the picker, but has no config form.** See §1.2. |
|
||||
|
||||
`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
|
||||
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).
|
||||
|
||||
**Zero `NotImplementedException`** across all five drivers. Every `NotSupportedException` is either a
|
||||
`catch` filter or a deliberate domain error.
|
||||
|
||||
### 1.2 Gaps found — AdminUI authoring & dispatch layer
|
||||
|
||||
Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
|
||||
|
||||
| # | Gap | Evidence | Impact |
|
||||
|---|---|---|---|
|
||||
| G-1 | **`DriverConfigModal.razor` has no `Calculation` case** — falls to the `default` "No typed config form" warning; no `CalculationDriverForm.razor` exists | `DriverConfigModal.razor:34-72`, default arm `:69-71` | `CalculationDriverOptions.RunTimeout` (`CalculationDriverOptions.cs:19`) is **unauthorable via the UI**. Create/rename still work, so degraded — not a hard block. **This is the same class as the Sql picker defect, one modal further downstream.** |
|
||||
| G-2 | **`DeviceModal.razor` is missing Sql, MTConnect, Calculation** | `DeviceModal.razor:42-74`, default arm `:71-73` | Mitigated: Sql/MTConnect hold connection at the **driver** level, and Test Connect still works via `BuildMergedProbeConfig` (`:180-184`). |
|
||||
| G-3 | **`DriverConfigModal.razor:77` tells Sql/MTConnect operators the wrong thing** — the single-connection branch is hardcoded to `Galaxy or Mqtt`, so Sql/MTConnect users are told the endpoint lives on the *device* when they just authored it on the driver | `DriverConfigModal.razor:77`, `:86-88` | Actively misleading, compounds G-2. |
|
||||
| G-4 | **No parity test guards `DriverConfigModal` or `DeviceModal`** — `RawDriverTypeDialogCoverageTests`/`…ParityTests` assert `DriverTypeNames` ↔ **picker** parity only | — | **This is exactly why G-1 and G-2 survived review.** The guard pattern exists and simply was not extended to the two downstream dispatch maps. |
|
||||
| G-5 | **`CsvColumnMap` has no typed columns for Sql, MQTT, MTConnect** | `CsvColumnMap.cs:340-452` | CSV import/export degrades to the raw `TagConfigJson` fallback while the 7 older drivers get typed columns. |
|
||||
| G-6 | **`RawBrowseCommitMapper` has no Sql branch** — falls to the generic `WriteSingleKey("address", …)` whose comment claims "Browsable drivers are all handled above" | `RawBrowseCommitMapper.cs:121-141`, fallback `:145` | Untrue since `SqlDriverBrowser` was registered (`EndpointRouteBuilderExtensions.cs:83`). Same failure mode the MTConnect branch (`:135-139`) was added to avoid: typed editor opens empty, blanks the field on save. |
|
||||
| G-7 | **`EquipmentTagConfigInspector` covers only 6 driver types** | `:24-29` | Pre-existing (OpcUaClient + Galaxy absent too); no new driver was added. |
|
||||
|
||||
### 1.3 Stale in-code comments (harmless at runtime, misleading to the next reader)
|
||||
|
||||
- `DriverTypeNames.cs:22-25` — Calculation described as "not-yet-registered"; it registers at `DriverFactoryBootstrap.cs:149`.
|
||||
- `TagConfigEditorMap.cs:25-28` + `TagConfigValidator.cs:27-28` — "`DriverTypeNames.Sql` deliberately absent until Task 11"; the constant exists (`DriverTypeNames.cs:57`) and the values are identical, so behaviour is correct.
|
||||
- `RawDriverTypeDialog.razor:2-4, 59-60` — "its factory lands in Wave C".
|
||||
- `CsvColumnMap.cs:322-324` — hardcodes `CalculationDriverType = "Calculation"` "Not yet a `DriverTypeNames` constant"; it has been one since `DriverTypeNames.cs:54`.
|
||||
- ~~`SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh
|
||||
instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`.~~ ✅ **Fixed** —
|
||||
the comment now says the premise was false when written and is true *now* because a config change
|
||||
respawns. See §9.
|
||||
|
||||
---
|
||||
|
||||
## 2. Open tracker issues — all 17 verified against source
|
||||
|
||||
**No issue was found stale.** 11 CONFIRMED, 4 PARTIALLY (core claim holds, a sub-claim is wrong or
|
||||
incomplete), 2 CANNOT VERIFY (correctly filed as hardware/fixture-gated).
|
||||
|
||||
| # | Title (abbrev.) | Verdict | Key evidence | Kind |
|
||||
|---|---|---|---|---|
|
||||
| **518** | `IRediscoverable` + `IHostConnectivityProbe` have no consumer; CLAUDE.md wrong | **CONFIRMED — worse than filed** | Only `+=` in `src/` are Galaxy self-wiring (`GalaxyDriver.cs:586`, `:221`). `GetHostStatuses()` has **zero call sites** outside the interface + implementations; `DriverHostStatuses` DbSet is referenced only by its declaration and a test. | Defect + doc error |
|
||||
| **507** | Discovered-node injection inert in v3 | **CONFIRMED** | `DriverHostActor.cs:986-1002` hard-`return;` + `#pragma warning disable CS0162` (restored `:1077`) | Deferred (deliberate guard) |
|
||||
| **519** | No Bad/Uncertain **sub-code** reaches a client | **CONFIRMED** | Collapse at `DriverInstanceActor.cs:1033-1041` (`statusCode >> 30`), re-expand at `OtOpcUaNodeManager.cs:3318-3322`. Enum is 3-state at `IOpcUaAddressSpaceSink.cs:165`. **Wider than filed** — `StatusFromQuality` also used at `:429/:506/:576/:761`, so alarm-condition Quality collapses too. | Design consequence |
|
||||
| **517** | Health published AFTER teardown | **CONFIRMED, fleet-wide** | `ModbusDriver.cs:244-250`, `MTConnectDriver.cs:579-590`, `S7Driver.cs:292-326` — all `Teardown…` then `WriteHealth(Unknown)`; `GetHealth()` is a plain field read | Defect (race) |
|
||||
| **516** | Driver config edits silently discarded | **CONFIRMED — survey now complete** | `DriverInstanceActor.cs:316` (only `_driver` assignment) + `DriverHostActor.cs:2565` (`Tell` existing child, no respawn) + `:653/:660` (green seal). **Affected: Modbus, FOCAS, OpcUaClient, + AbLegacy (issue missed it), + Sql (deliberate/documented).** AbCip/TwinCAT re-parse ✅; Galaxy fails **loud** (throws, `GalaxyDriver.cs:600-641`) | Defect |
|
||||
| **515** | Sparkplug `DataSet`/`Template`/`PropertySet` | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
|
||||
| **514** | Rebirth unarmable on a plant that hasn't birthed | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
|
||||
| **513** | Meaningless `payloadFormat` in Sparkplug blob | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
|
||||
| **512** | `.Browser` → `.Driver` layering | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
|
||||
| **511** | `ActAsPrimaryHost` does nothing | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
|
||||
| **510** | `_canRebirth` captured at browse-open | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
|
||||
| **509** | Metric name with `/` unbrowse-committable | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
|
||||
| **508** | MQTT write-through | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
|
||||
| **507**→ see above | | | | |
|
||||
| **491** | Continuous-historization live gate | **CANNOT VERIFY** (needs VPN'd gateway) | Code **is** complete: `AddressSpaceApplier.cs:238`→`:540`→`ActorHistorizedTagSubscriptionSink.cs:27-38`→`ContinuousHistorizationRecorder.cs:84`, wired `ServiceCollectionExtensions.cs:435` | Deferred test |
|
||||
| **489** | Hot-rebind renamed raw tag | **CONFIRMED** | No implementation exists | Deferred feature |
|
||||
| **481** | Comms-loss → scripted-alarm quality (Layer 4) | **CONFIRMED** | `DriverHostActor.cs:1356-1379` iterates `_alarmNodeIdByDriverRef` only. **Issue is wrong that the per-driver ref set is missing** — `_nodeIdByDriverRef` exists at `:193`; only the fan-out into the mux is absent, so the work is *smaller* than filed | Deferred feature |
|
||||
| **468** | Wave-0 browser tree render | **CANNOT VERIFY** (fixture) | Code path present (`AbCipDriver.cs:1092-1095`, `LibplctagTagEnumerator.cs:29-37`); `ab_server` returns `ErrorUnsupported` for CIP Symbol Object enumeration | Deferred verification |
|
||||
|
||||
### 2.1 Cross-cutting observations
|
||||
|
||||
1. **#518 → #507 are the same failure, stacked.** The event has no subscriber *and* its downstream
|
||||
handler hard-returns. **Fixing either alone changes nothing observable** — schedule them together.
|
||||
2. **#516 is a superset of part of #489.** For the five drivers that discard reinit config, the
|
||||
renamed-tag-never-rebinds symptom follows mechanically (their `_tagsByRawPath` still keys on the
|
||||
old RawPath). Re-scope #489 after #516.
|
||||
3. **#519 and #481 share the same `statusCode >> 30` collapse idiom** at three independent sites
|
||||
(`DriverInstanceActor.cs:1033`, `ScriptedAlarmEngine.cs:745`, `:777`). Widening the status path
|
||||
must cover all three.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dead seams — authored, shipped, never executed
|
||||
|
||||
**The most consequential category in this register**, and the one nothing in the code flags. Three
|
||||
subsystems are fully built and reachable by operators, but no production code path executes them.
|
||||
|
||||
### 3.1 Node-level ACLs are authorable, deployed — and unenforced
|
||||
|
||||
- `IPermissionEvaluator`, `TriePermissionEvaluator`, `PermissionTrieCache`, `PermissionTrieBuilder`
|
||||
have **zero references outside `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and their own
|
||||
tests** (independently re-verified for this report: every other grep hit is a `bin/`/`obj/` XML doc artifact).
|
||||
- `OtOpcUaNodeManager` contains **no reference to `Permission` or `Evaluator` at all.**
|
||||
- Meanwhile `ClusterAcls.razor` + `AclEdit.razor` let operators author `NodeAcl` rows, and
|
||||
**`ConfigComposer.cs:51` snapshots them into every deployment artifact.**
|
||||
|
||||
**Impact:** an operator authors a deny rule, deploys it green, and it has no effect. Actual
|
||||
enforcement is coarse LDAP roles plus the realm-qualified `WriteOperate` gate in the node manager —
|
||||
and that is a **write** gate; reads carry no per-node ACL check.
|
||||
**`docs/ReadWriteOperations.md:13,21` states the opposite** (see §7.1) — it is the single
|
||||
highest-risk doc error found.
|
||||
|
||||
### 3.2 `IRediscoverable` + `IHostConnectivityProbe` raise into the void
|
||||
|
||||
Gitea #518/#507. Nine drivers raise; nothing consumes. `DriverHostStatus` table exists
|
||||
(`OtOpcUaConfigDbContext.cs:48`, migration `20260715230637_V3Initial.cs:108`) with a doc-comment
|
||||
claiming a writer — **nothing ever writes a row.**
|
||||
|
||||
**Impact:** an Agent/PLC/Galaxy restart leaves a stale address space behind a Healthy driver, and the
|
||||
per-host connectivity table is permanently empty.
|
||||
|
||||
### 3.3 `DriverTypeRegistry` is vestigial
|
||||
|
||||
Independently verified for this report: `src/Core/…Core.Abstractions/DriverTypeRegistry.cs:20` is
|
||||
referenced **only** by `tests/Core/…/DriverTypeRegistryTests.cs`. Nothing registers metadata at
|
||||
startup; nothing validates `DriverInstance.DriverType` against it. Its `AllowedNamespaceKinds` field
|
||||
was retired with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`).
|
||||
|
||||
**Knock-on:** the driver **stability tier** does not come from here either. The live source is
|
||||
`DriverFactoryRegistry.cs:46` (`DriverTier tier = DriverTier.A`) and **no factory in `src/Drivers/`
|
||||
passes a tier** — so all 12 drivers run Tier A, and the Tier-C-only protections are dormant for
|
||||
every driver: `MemoryRecycle.cs:54` (`when _tier == DriverTier.C`) and
|
||||
`ScheduledRecycleScheduler.cs:43` (refuses unless Tier C). `docs/v2/driver-stability.md` still
|
||||
assigns Galaxy and FOCAS to Tier C.
|
||||
|
||||
### 3.4 Related dead/dormant code
|
||||
|
||||
| Item | Evidence |
|
||||
|---|---|
|
||||
| `IDriverConfigEditor` — interface, zero implementors, zero production consumers | `Core.Abstractions/IDriverConfigEditor.cs:9`; superseded by `TagConfigEditorMap` / `DriverConfigModal` |
|
||||
| `GenericDriverNodeManager` — explicitly **not** a production dispatch path | `Core/OpcUa/GenericDriverNodeManager.cs:71` ("verified 07/#10: zero production references") |
|
||||
| `IDriverSupervisor` — Tier-C recycle machinery has **zero implementations**, yet the parser still validates `RecycleIntervalSeconds` | archreview 01/U-2 |
|
||||
| Resilience **bulkhead** — documented, defaulted, parsed, AdminUI-authorable; `Build` never adds a concurrency limiter | archreview 01/U-6 |
|
||||
| `WriteIdempotentAttribute` — zero readers; write dispatch hardcodes `isIdempotent: true` | archreview 01/S-8 = 03/S12 |
|
||||
| ~60 lines of unreachable code retained behind #507's guard | `DriverHostActor.cs:1002`, `#pragma warning disable CS0162` |
|
||||
| Three empty `Driver.Historian.Wonderware*` directories (0 `.cs`, no `.csproj`, absent from `.slnx`) | verified for this report — only stale `bin/`+`obj/` remain |
|
||||
|
||||
---
|
||||
|
||||
## 4. In-code deferrals by subsystem
|
||||
|
||||
The tree is unusually clean on classic markers: **9 `TODO`s across 1,812 files; zero
|
||||
`FIXME`/`HACK`/`XXX`/`WORKAROUND`; zero `#if false`; zero `[Obsolete]` on project code; zero
|
||||
commented-out DI registrations.** Real deferred work lives in prose doc-comments and skipped tests.
|
||||
|
||||
### 4.1 Highest-consequence
|
||||
|
||||
| Item | Evidence |
|
||||
|---|---|
|
||||
| **Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" |
|
||||
| **Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` |
|
||||
| **Galaxy writes can never confirm a COM commit** — empty status array ⇒ provisional Good, metered `galaxy.writes.unconfirmed` | `GatewayGalaxyDataWriter.cs:44,328,354` — **cross-repo**, blocked on mxaccessgw |
|
||||
| **Subscription liveness undetectable** — `ISubscriptionHandle` is opaque | `DriverInstanceActor.cs:527` (#488) — well-reasoned, deferred "until a real occurrence" |
|
||||
| **`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing | `RawTree.razor:15,426` — **verify**; may be reachable now via `DiscoveryDriverBrowser` |
|
||||
|
||||
### 4.2 Per-driver
|
||||
|
||||
- **S7** — array **writes** unsupported entirely (`S7Driver.cs:1139`); several array *read* types unsupported (`:386,404,413,419,428,442,465,807,959`) — all fail fast at config-validation; legacy C-area counter reinterpretation live-hardware-gated (`:759`); Counter BCD on S7-300/400 undecoded.
|
||||
- **AbCip** — ALMD-only alarm projection, ALMA deferred (`AbCipAlarmProjection.cs:16,324`); no CIP Template Object reader, UDT layouts declaration-driven (`AbCipDriverOptions.cs:172`); whole-UDT writer deferred (`LibplctagTagRuntime.cs:190`); `AckCmd` pulse must be wired client-side (`:30`).
|
||||
- **Sql** — `SqlTagModel.Query` deferred to P3, **enforced end-to-end** across parser, validator, planner and editor (`SqlProvider.cs:22,37`; `SqlEquipmentTagParser.cs:24,103`; `SqlGroupPlanner.cs:44,249`; `SqlTagConfigModel.cs:114`) — exemplary deferral discipline. Postgres/ODBC dialects deferred behind `ISqlDialect`.
|
||||
- **MTConnect** — `TIME_SERIES` vectors deferred to P1.5; `DATA_SET`/`TABLE` coded `BadNotSupported` with a null value rather than approximated (`MTConnectObservationIndex.cs:53,272`) — deliberately chosen over a Good-coded lie. `RawTagEntry.DeviceName` ignored for routing (two `/raw` Devices under one driver share an Agent connection) — "a design change, not a bug fix". No DataType-vs-blob validation at authoring.
|
||||
- **MQTT** — `MaxPayloadBytes` is a constructor knob, not operator-facing (`MqttSubscriptionManager.cs:170`).
|
||||
- **TwinCAT** — Flat-mode struct expansion carries a **LIVE RISK** note, unverified against a real TC3 target (`AdsTwinCATClient.cs:270`); VirtualTree-mode browse unverified; TC2 coverage, multi-hop AMS route and lab rig all hardware-blocked (`docs/v3/twincat-backlog.md`).
|
||||
- **FOCAS** — forces read-only regardless of authored `writable:true`; servo-load semantics inferred from the wire, unconfirmed against a real CNC (`FocasWireClient.cs:944`); `"Backend": "unimplemented"` is a **deliberate fail-fast stub**, not unfinished work.
|
||||
- **Historian.Gateway** — String/DateTime/Reference writes deferred, gated on the analog SQL write path; `UInt16 → Uint4` widening because the `UInt2` write path is deferred upstream (`HistorianTypeMapper.cs:27-51`).
|
||||
|
||||
### 4.3 Fleet-wide OPC UA gaps
|
||||
|
||||
- **Array writes deferred across all drivers**; **multi-dimensional arrays (`ValueRank>1`) unsupported**; **array historization** is an opaque blob with no per-element history (`docs/Uns.md:288-292`, `AddressSpaceApplier.cs:873`, `OtOpcUaNodeManager.cs:1764`).
|
||||
- **`HistoryUpdate`** — permission bit `1<<12` shipped but excluded from every composite bundle; no service surface (`NodePermissions.cs:34`).
|
||||
- **`Utils.Log*` is `[Obsolete]`** in SDK 1.5.378, suppressed at 8 sites in `OtOpcUaNodeManager` pending an `ITelemetryContext` rewire.
|
||||
|
||||
### 4.4 Skipped tests — 36, from 4 reason constants
|
||||
|
||||
| Count | Reason | Assessment |
|
||||
|---|---|---|
|
||||
| 18 | `DiscoveryInjectionDormantV3` | **Real** — blocked on #507 |
|
||||
| 13 | `EquipmentTagsDarkBatch4` | **STALE** — the reason says these unblock "at Batch 4"; Batch 4 shipped as v3.0 and the path was *retired* (`AddressSpaceApplier.cs:415` calls it "the vestigial equipment-tag path"). These will never unskip as written — delete or rewrite the reason. |
|
||||
| 4 + 1 | `EquipmentDeviceBindingRetired`, `DarkBatch4` | Intentional tombstones preserving retirement rationale — fine as-is |
|
||||
|
||||
Plus **4 whole OpcUaClient test suites that are scaffold-only**, awaiting an in-process OPC UA server
|
||||
fixture that was never built (`OpcUaClientSubscribeAndProbeTests`, `…DiscoveryTests`,
|
||||
`…ReadWriteTests`, `…HistoryTests`, all `:10`).
|
||||
|
||||
All env-gated integration suites **skip cleanly** when their vars are absent. The MTConnect gate is
|
||||
notably a **real `/probe` reachability + payload-shape check** (`MTConnectAgentFixture.cs:42`), not
|
||||
mere env-var presence, so it cannot pass vacuously.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open live gates
|
||||
|
||||
| Gate | Status | Evidence |
|
||||
|---|---|---|
|
||||
| **Continuous-historization value capture** (#491) | **OPEN** — code complete, unproven in production | `CLAUDE.md:710`; no gate-passed commit exists |
|
||||
| **Wave-0 universal discovery browser** full tree render (#468) | **OPEN** — fixture-blocked | tracking doc §Wave 0; named as blocking BACnet browse |
|
||||
| **archreview R2-03** — VT Good→Bad on a data-fed rig | **OPEN** — explicitly "live-blocked on this data-less rig" | `archreview/plans/STATUS.md` |
|
||||
| **archreview R2-10** — force breaker-OPEN live | **OPEN** — breaker-OPEN state never forced live | `archreview/plans/R2-10-*.tasks.json` |
|
||||
| **Driver-reconfigure-while-faulted** Task 3 | **OPEN** — task subject literally "DEFERRED" | `2026-07-14-driver-reconfigure-while-faulted-plan.md.tasks.json` |
|
||||
| Galaxy `ScanStateProbeParityTests` | **OPEN** — licence-gated (one `$WinPlatform` on this box) | `docs/v2/Galaxy.ParityRig.md:180` |
|
||||
| **v2 GA exit criteria** — FOCAS live-CNC wire smoke (#54), OPC UA CTT/compliance pass, Ignition 8.3 redundancy cutover | **OPEN** — manual/hardware/external-tool | `docs/v2/v2-release-readiness.md:105-120` |
|
||||
| mesh Phase 4 | ✅ **PASSED** `1281aebf` | CLAUDE.md said open — **corrected**, §7.2 |
|
||||
| mesh Phase 5 | ✅ **PASSED** `f134935f` | CLAUDE.md said pending — **corrected**, §7.2 |
|
||||
| auto-down 1-vs-1 crash-the-oldest | ✅ **PASSED** — Phase 7 drill D4, `c50ebcf7` | CLAUDE.md said open — **corrected**, §7.2 |
|
||||
|
||||
**Note on the 14 archreview `deferred-live` tasks across 8 plans:** `archreview/plans/STATUS.md:242-305`
|
||||
records a 2026-07-13 live pass closing several (R2-02, R2-07, R2-11, R2-04, R2-05-partial) and
|
||||
2026-07-15 closures for R2-01/R2-08/R2-06 — **but the `.tasks.json` files were never updated.** Only
|
||||
R2-03 and R2-10 have no closure record anywhere.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan bookkeeping — stale vs. live
|
||||
|
||||
**59 of 78 `docs/plans/*.tasks.json` are 100 % complete.** Of the remaining 19, most are stale
|
||||
bookkeeping, not backlog.
|
||||
|
||||
### 6.1 STALE — work shipped, file never updated (~140 phantom "pending" tasks)
|
||||
|
||||
| File | Recorded | Reality |
|
||||
|---|---|---|
|
||||
| `2026-07-24-mtconnect-driver.md.tasks.json` | 23 pending | Merged at master tip `90bdaa44`, 5-leg live gate PASSED |
|
||||
| `2026-07-24-mqtt-sparkplug-driver.md.tasks.json` | 27 pending | All 27 done, both phases live-gated |
|
||||
| `2026-07-24-sql-poll-driver.md.tasks.json` | 22 pending | Merged `4ad54037` + follow-ups `28c28667` |
|
||||
| `2026-07-24-modbus-rtu-driver.md.tasks.json` | 11 pending | Merged `0f38f486` (#495), live gate PASSED |
|
||||
| `2026-06-26-otopcua-historian-gateway-integration.md.tasks.json` | 21 pending | Shipped as PR #423, live-validated |
|
||||
| `2026-06-15/16-stillpending-phase-{0-1,2,3,4}.md.tasks.json` | 12+9+10+8 pending | Shipped — audit §D cites `1e95856b`, `ada01e1a`, `fc8121cb`, `3e609a2b`, `70e6d3d2`, `c236263e`, `bd8fee61`, `5e27b5f7`, `fcb38014` |
|
||||
| `2026-07-22-per-cluster-mesh-program.md.tasks.json` | Phase 2 in_progress, 3–7 pending | Plan body `:331-342` says **program COMPLETE** |
|
||||
| `2026-06-12-historian-tcp-transport.md.tasks.json` | 12 pending | **OBSOLETE** — targets the retired Wonderware sidecar |
|
||||
|
||||
### 6.2 GENUINELY LIVE
|
||||
|
||||
| File | Open | Note |
|
||||
|---|---|---|
|
||||
| **`2026-05-29-adminui-followups.md.tasks.json`** | **19 pending / 0 completed** | **The largest unexecuted plan in the tree.** Generic `CollectionEditor<TRow>`; per-driver device/tag editors ×7; typed resilience form; `RoleMapper.Merge` + DB-backed LDAP→role mapping + `RoleGrants.razor` + FleetAdmin authz. **Several items look superseded by v3's `TagConfigEditorMap` — verify overlap before executing.** |
|
||||
| `2026-06-19-followups-batch.md.tasks.json` | 4 pending + 1 partial | B4 F10b surgical DataType/IsArray writes and B5 alarm-severity `SetSeverity` are **OPEN-by-choice, not built** |
|
||||
| `2026-06-26-otopcua-fixedtree-followups.md.tasks.json` | 1 pending | Task 11 build + regression |
|
||||
| `2026-05-26-akka-hosting-alignment-plan.md.tasks.json` | 5 partial | F8/F9/F10/F13/F14 — oldest open items; **likely superseded by v3, verify** |
|
||||
| `2026-05-28-adminui-driver-pages-plan.md.tasks.json` | 1 partial | 10.4 manual smoke checklist |
|
||||
|
||||
`docs/plans/2026-07-23-mesh-phase4-…tasks.json` Task 9 was the only `deferred`-flagged mesh task —
|
||||
**it has since landed** (`3a590a0c`), so that file is now stale too.
|
||||
|
||||
### 6.3 Review-directory state
|
||||
|
||||
- **`code-reviews/`** — 51 modules, **zero Open findings**. One textual "Left Open pending a decision"
|
||||
survives in a finding body (`Configuration/findings.md:254`, LDAP collation) whose status field
|
||||
reads Deferred — the only index inconsistency.
|
||||
- **`code-reviews/` coverage gap** — **10 shipped source projects have never been reviewed**:
|
||||
`Driver.Calculation`, `Driver.Historian.Gateway`, `Driver.Mqtt` (+`.Browser`, `.Contracts`),
|
||||
`Driver.MTConnect` (+`.Contracts`), `Driver.Sql` (+`.Browser`, `.Contracts`). Meanwhile three
|
||||
modules review the **retired** Wonderware backend.
|
||||
- **`archreview/`** — `00-OVERALL.md:60-79` carries an explicit "Carried open … no remediation branch
|
||||
targeted them" block. The largest coherent batch is the **failure-visibility trio**: 01/S-1
|
||||
(`AddressSpaceApplier` swallows every sink failure, deploy reports Applied even when broken), 03/S4
|
||||
(primary gate default-allow on unknown role), 06/S-1 (Galaxy optimistic write success).
|
||||
- **`stillpending.md` does not exist** — the backlog is `docs/plans/2026-06-18-stillpending-backlog-audit.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Documentation corrections
|
||||
|
||||
### 7.1 Applied in this pass
|
||||
|
||||
| File | Was | Now |
|
||||
|---|---|---|
|
||||
| `CLAUDE.md` §Change Detection (`:156`) | "The server's `DriverHost` consumes the signal and rebuilds the address space" | ⚠️ nothing consumes it; names `DriverHostActor.cs:986`, #518/#507, and the four affected drivers |
|
||||
| `CLAUDE.md` §Redundancy (`:272`) | auto-down "live gate is still open … docker-dev is a single six-node mesh" | gate **CLOSED** by Phase 7 drill D4 (`c50ebcf7`); the six-node-mesh claim also contradicted CLAUDE.md's own Phase 6 paragraph |
|
||||
| `CLAUDE.md` §Telemetry (`:383`) | Phase 5 "code-complete on `feat/mesh-phase5`, live gate pending" | merged `35552a96`; live gate PASSED `f134935f` |
|
||||
| `CLAUDE.md` §Phase 4 (`:493`, `:502-503`) | `ScriptedAlarmState` "dropping it is a deferred follow-up, tracked as Task 9"; "Task 9 and the live gate (Task 10) are still open" | table **dropped** (`3a590a0c`, migration `20260723183050_DropScriptedAlarmStateTable`); live gate PASSED `1281aebf` |
|
||||
| `docs/plans/2026-07-24-driver-expansion-tracking.md` | Modbus RTU "pending merge"; SQL poll "📝 Plan ready (22 tasks)"; **an "Executing a plan" table instructing you to rebuild both** | both marked ✅ Done with merge SHAs; the two shipped rows struck from the command table; Wave 1/2 headings and §Next actions corrected |
|
||||
| `docs/drivers/README.md` | `DriverTypeRegistry` "registered at startup"; "namespace kinds (Equipment + SystemPlatform)"; no Sql/Calculation rows; "Modbus TCP" only | registry marked vestigial + tier truth; namespace line corrected to Raw+UNS; Sql + Calculation rows added; Modbus row covers RTU-over-TCP |
|
||||
| `docs/drivers/TwinCAT.md` (`:95`, `:99-103`, `:151`) | "so the address space is rebuilt" / "so Core rebuilds the address space" | raises-but-unconsumed caveat, mirroring the wording `Mqtt.md`/`MTConnect.md` already use correctly |
|
||||
| `docs/drivers/Galaxy.md` (`:73`) | `IRediscoverable` row with no caveat | same caveat added |
|
||||
| `docs/v2/Runtime.md` (`:106`) | "named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink`" | gRPC to HistorianGateway + `LocalDbStoreAndForwardSink` |
|
||||
| **`docs/ReadWriteOperations.md`** (`:13`, `:21` + banner) | "ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch"; "**A denied read never hits the driver**" | Both struck through and corrected; banner states plainly that **no per-node ACL gate exists on any operation** and that `GenericDriverNodeManager` is test scaffolding. **This was the highest-risk doc error found.** |
|
||||
| **`docs/IncrementalSync.md`** (banner) | whole "Rebuild flow" + generation-publish path presented as current | Banner: `GenericDriverNodeManager` is non-production, rediscovery has no consumer, `sp_PublishGeneration`/`sp_ComputeGenerationDiff` `RAISERROR`, `DraftRevisionToken` gone; names the one live path (deploy artifact) |
|
||||
| **`docs/AddressSpace.md`** (banner) | "single custom namespace"; `Phase7Applier`/`Phase7Composer`/`GalaxyTagPlan`; `SystemPlatform` | Banner: two namespaces w/ `V3NodeIds`, the `AddressSpace*` rename (`40e8a23e`), `AddressSpaceComposition` as the result type, `SystemPlatform` retired |
|
||||
| `docs/v2/phase-7-status.md`, `v2-release-readiness.md`, `redundancy-interop-playbook.md`, `AdminUI-rebuild-plan.md` (banners) | present-tense claims about types that no longer exist | Dated "⚠️ Historical" banners naming the specific dead types. `v2-release-readiness.md`'s banner also flags that its `:41` "ACL enforcement Closed" row is false, while noting its unchecked GA exit criteria **are** still live. (`docs/StatusDashboard.md` already self-declares Superseded — left alone.) |
|
||||
| `docs/OpcUaServer.md` (`:14`, `:51`), `docs/README.md` (`:65`), `docs/drivers/OpcUaClient-Test-Fixture.md` (`:127`) | `WriteAlarmState`; `LdapUserAuthenticator` ×2; `RedundancyCoordinator` | `WriteAlarmCondition`; `LdapOpcUaUserAuthenticator`; `RedundancyStateActor` + `IRedundancyRoleView` |
|
||||
| `docs/Historian.md` | no mention of #491 or the provisioning gap | ⚠️ block added: continuous value capture is **code-complete but unproven in production**, with the wiring chain; plus the `EnsureTags`-skips-existing-tags gap |
|
||||
|
||||
### 7.2 Recorded, NOT applied — needs a decision or a rewrite, not a line edit
|
||||
|
||||
The three highest-priority items in this list were **bannered rather than rewritten** in this pass
|
||||
(cheap, stops the misleading, preserves the record) — the underlying rewrite is still owed. They now
|
||||
appear in §7.1 as well.
|
||||
|
||||
| Priority | File | Problem |
|
||||
|---|---|---|
|
||||
| **1 (security-relevant)** | `docs/ReadWriteOperations.md:13,21` | **Bannered + struck through.** Claimed ACL enforcement via `WriteAuthzPolicy` + `AuthorizationGate` + `NodeScopeResolver` and that "a denied read never hits the driver". All four names have zero hits in `src/`; §3.1 shows there is no read-path ACL gate at all. **Still owed:** a proper §-rewrite against the real node-manager write gate. Same false claim at `docs/v2/v2-release-readiness.md:41` (bannered). |
|
||||
| **2** | `docs/IncrementalSync.md` | **Bannered.** Entire "Rebuild flow" (`:37-47`) built on non-production `GenericDriverNodeManager`; `:26-34` cites `sp_PublishGeneration`/`sp_ComputeGenerationDiff`, both retired stubs that `RAISERROR` (`20260715230637_V3Initial.StoredProcedures.cs:194`); `:31` cites `DraftRevisionToken` (0 hits); `:49-51` describes a `ScopeHint` no consumer reads. **Still owed:** rewrite around the deploy-artifact path, or archive to `docs/v1/`. |
|
||||
| **2** | `docs/AddressSpace.md` | **Bannered.** v2-era throughout: `:7,42` claim a single namespace (v3 has two); `:7,48,70` cite `Phase7Applier` + `GalaxyTagPlan` + a nonexistent file path; `:62-64` repeats the rediscovery claim. **Still owed:** rewrite §"Root folder" + §"NodeId scheme" against `V3NodeIds` / `AddressSpaceRealm`. |
|
||||
| 3 | 6 docs, ~20 citations | **`Phase7*` → `AddressSpace*`** rename (`40e8a23e`). Mostly mechanical, **except three that are not renames** and need the real member identified first: `Phase7CompositionResult` → **`AddressSpaceComposition`** (`AddressSpaceComposer.cs:13`, verified for this report); `Phase7Composer.ExtractTagFullName` → nearest live seam is `ScriptTagCatalog.ExtractFullNameFromTagConfig`; **`Phase7EngineComposer.RouteToHistorianAsync` is simply gone** — the real seam is `HistorianAdapterActor` → `IAlarmHistorianSink`. |
|
||||
| 3 | `docs/v2/driver-specs.md` | Covers 8 of 12 drivers (missing MQTT, MTConnect, Sql, Calculation, and the Modbus `Transport` selector) while `docs/drivers/README.md` calls it authoritative "for **every** driver". Either add four sections or downgrade the claim. |
|
||||
| 3 | `docs/v2/driver-stability.md:47-54` | Puts Galaxy and FOCAS in Tier C; `docs/drivers/README.md` says Tier A for both. **Moot at runtime** — see §3.3 — but the two docs contradict each other. |
|
||||
| 3 | `docs/drivers/Sql.md`, `docs/drivers/Calculation.md` | **Do not exist.** Sql is documented only in design/plan/research files; Calculation only at `docs/Raw.md:66-69`. |
|
||||
| — | `docs/operations/2026-07-16-secrets-clustered-master-key.md:38` | `NoOpSecretReplicator` exists **only** in a test. **Investigate before editing** — the sentence makes a security-relevant claim ("no built-in cross-node replication today"), so it needs the real behaviour identified, not a rename. Left untouched deliberately. |
|
||||
| — | `docs/v2/driver-specs.md` §2 | `docs/drivers/Modbus.md:10-11` points readers here for the Modbus config shape, and this file omits the `Transport` selector. Folded into the driver-specs item above. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Recommended sequencing
|
||||
|
||||
1. ~~**§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or
|
||||
remove the authoring UI and say plainly that ACLs are not enforced. Either way fix
|
||||
`docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~
|
||||
✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9.
|
||||
(`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.)
|
||||
2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right
|
||||
that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
|
||||
so it was deleted rather than fixed. See §9.
|
||||
3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
|
||||
✅ **Done.** See §9. #489 is still open and should now be re-scoped.
|
||||
4. ~~**G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
|
||||
have caught G-1 and G-2 for free, and this class has now recurred twice.~~ ✅ **Done**, together with
|
||||
G-1, G-2, G-3, G-5 and G-6. See §9.
|
||||
5. ~~**Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
|
||||
(§6.2) is visible as the real backlog it is.~~ ✅ **Done.** See §9.
|
||||
6. ~~**§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C
|
||||
machinery; today it is documented, authorable and dormant.~~ ✅ **Documented**; the keep-or-delete
|
||||
code decision is Gitea **#522**. See §9.
|
||||
|
||||
---
|
||||
|
||||
## 9. Execution log
|
||||
|
||||
Remediation of §8, on branch `feat/deferment-remediation`. Status is updated in the **same commit** as
|
||||
the work, so this register never disagrees with the tree.
|
||||
|
||||
| §8 item | Status | Commit |
|
||||
|---|---|---|
|
||||
| 1. ACL enforcement decision | ✅ **Done** — decided *make non-enforcement explicit*; Gitea **#520** tracks the wire-up | `d1e88dc4` |
|
||||
| 2. #518 + #507 | ✅ **Done** — `IRediscoverable` consumed as a re-browse prompt; #507's injection path **deleted**; `IHostConnectivityProbe` half split to Gitea **#521** | `09a401b8`, `97c9f4b4` |
|
||||
| 3. #516 config discard | ✅ **Done** — seam respawn + re-parse on 3 drivers; Sql/FOCAS deliberately respawn-only | `2dc19f30` |
|
||||
| 4. G-4 dispatch-map parity | ✅ **Done** — G-1…G-6 all closed, live-verified on docker-dev | `abacf4cf` |
|
||||
| 5. Bookkeeping sweep | ✅ **Done** — 13 plan files closed, 11 archreview gates written back | `03658c2e` |
|
||||
| 6. Tier truth | ✅ **Done** — docs reconciled; keep-or-delete is Gitea **#522** | `03658c2e` |
|
||||
|
||||
### 2026-07-27 — §8.1 ACL non-enforcement made explicit
|
||||
|
||||
Decision: **do not wire the evaluator, do not delete it** — state plainly that it does not run, and
|
||||
track the wire-up as **Gitea #520**.
|
||||
|
||||
Applied:
|
||||
|
||||
- `docs/security.md` — the `NodeScope`/`PermissionTrie`/"Dispatch gate" block now leads with a
|
||||
**What is enforced today** table (the three real checks) before the design material, which is
|
||||
labelled *Designed but not wired*.
|
||||
- `docs/ReadWriteOperations.md` — **rewritten**, not bannered.
|
||||
- `docs/v2/acl-design.md` — ⚠️ NEVER WIRED banner.
|
||||
- `docs/v2/v2-release-readiness.md` §"Security — Phase 6.2 dispatch wiring" — the CLOSED claim marked
|
||||
as not holding.
|
||||
- `ClusterAcls.razor` + `AclEdit.razor` — warning alerts stating rules are stored and deployed but
|
||||
never evaluated.
|
||||
|
||||
**Four things this pass found that §7 and the original audit missed** — all worse than what was
|
||||
already recorded:
|
||||
|
||||
1. **`docs/security.md` was the highest-risk doc, not `ReadWriteOperations.md`.** `:115` claimed an
|
||||
anonymous session is "default-denie[d] any node a session has no ACL grant for". The opposite is
|
||||
true: an anonymous session can Browse, Read, Subscribe and HistoryRead the **entire** address
|
||||
space. It is refused only writes and alarm acks — and only because it carries no roles.
|
||||
2. **Three of the five documented data-plane role strings do nothing.** `OpcUaDataPlaneRoles` declares
|
||||
exactly `WriteOperate` and `AlarmAck`. `ReadOnly`, `WriteTune` and `WriteConfigure` are compared
|
||||
against nowhere in `src/`, so mapping a group to `WriteTune` grants nothing and mapping one to
|
||||
`ReadOnly` restricts nothing. The doc called all five "exact, case-insensitive, and **code-true**".
|
||||
3. **`ReadWriteOperations.md` was fiction well beyond the ACL claims.** `OnReadValue` has **zero**
|
||||
occurrences in `src/` — the whole documented read path did not exist. Reads never reach a driver at
|
||||
all: the node manager is push-model and the SDK serves a client Read from the cached pushed value.
|
||||
`WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef` and `IRoleBearer` are likewise
|
||||
zero-hit, and `CapabilityInvoker` is not referenced by the `OpcUaServer` project at all.
|
||||
4. **`v2-release-readiness.md` claimed a whole enforcement layer shipped.** Beyond the `:41` row §7.1
|
||||
already flagged, `:46-50` describe `FilterBrowseReferences`, `GateCallMethodRequests`,
|
||||
`MapCallOperation`, `AuthorizationBootstrap` and `Node:Authorization:*` config keys as Closed or
|
||||
Partial. None of them exist. Whatever landed on the v2 branch did not survive into the shipped tree.
|
||||
|
||||
Also recorded in #520 and not previously known: every existing evaluator unit test runs in
|
||||
`PermissionTrieBuilder`'s no-`scopePaths` *deterministic test mode*, so the production hierarchy path
|
||||
is effectively untested; and `NodeAcl.ScopeId` has no FK or existence check, so scope ids dangle
|
||||
silently.
|
||||
|
||||
### 2026-07-27 — §8.2 rediscovery as a signal; injection deleted
|
||||
|
||||
Decision: **signal, not mutation.**
|
||||
|
||||
`DriverInstanceActor` now consumes `IRediscoverable.OnRediscoveryNeeded` (attach in `PreStart`, detach
|
||||
in `PostStop`) and carries it on the driver-health snapshot to `/hosts` as a **re-browse chip**. It is
|
||||
advisory — the served address space is unchanged, because v3 authors raw tags through `/raw`
|
||||
browse-commit and a runtime graft would materialise nodes nobody approved.
|
||||
|
||||
**#507's injection path was deleted, not fixed.** §2 recorded it as a "deferred (deliberate guard)".
|
||||
That was too generous: its two inputs — `EquipmentNode.DriverInstanceId` and `EquipmentTags` — are
|
||||
*structurally empty* in v3, so removing the guard would have changed a log line and injected nothing.
|
||||
Deleted with it: `HandleDiscoveredNodes`, `PartitionDiscoveredByDeviceHost`,
|
||||
`ApplyDiscoveredPlansForDriver`, the redeploy re-inject tail, `DiscoveredNodeMapper`,
|
||||
`AddressSpaceApplier.MaterialiseDiscoveredNodes`, `OpcUaPublishActor.MaterialiseDiscoveredNodes`, the
|
||||
connect-time discovery loop, and 4 files' worth of tests. `ITagDiscovery` stays — the `/raw` browse
|
||||
picker drives it through `Commons/Browsing/DiscoveryDriverBrowser`, which never went through the actor.
|
||||
|
||||
**§4.4's "18 `DiscoveryInjectionDormantV3` — Real, blocked on #507" was wrong.** They were v2
|
||||
characterization tests asserting an equipment-rooted graft (`EquipmentRootNodeId == "EQ-1"`) that v3
|
||||
cannot produce. They could never have unskipped as written; they are deleted. Skipped tests in
|
||||
`Runtime.Tests` went 31 → 13.
|
||||
|
||||
**The planned drift detector was deliberately NOT built.** The plan proposed diffing each connect-time
|
||||
discovery pass against the authored raw tags. Reading the drivers killed it: **Modbus, S7, MQTT,
|
||||
AbLegacy and Sql all echo authored config from `DiscoverAsync`** rather than browsing the device, so
|
||||
the diff is permanently empty for them. It would have been another plausible-looking inert seam —
|
||||
precisely the class this register exists to remove. That also removed the last consumer of the
|
||||
connect-time loop, which is why the loop went too: it was browsing real devices up to ~15× per connect
|
||||
and dropping the result.
|
||||
|
||||
Two traps worth keeping:
|
||||
|
||||
- `PublishHealthSnapshot` dedups on a health fingerprint, and a rediscovery raise changes none of the
|
||||
four fields it hashed. The timestamp had to join the tuple or the dedup swallows the signal.
|
||||
**Verified by reverting the one line — 3 of the 5 new tests go red.**
|
||||
- The shared `StubDriver.GetHealth()` returns `DateTime.UtcNow`, so its fingerprint differs every call
|
||||
and the dedup never engages. A dedup test built on it would pass whether or not the fix is present;
|
||||
the new tests use a stub with stable health.
|
||||
|
||||
`IHostConnectivityProbe` was **not** resolved — it is a keep-or-delete decision, and the
|
||||
`DriverHostStatus` table was re-created deliberately in the v3 initial migration, so deleting on
|
||||
inference would be wrong. Split to Gitea **#521** with both options costed.
|
||||
|
||||
### 2026-07-27 — §8.5 bookkeeping + §8.6 tier truth
|
||||
|
||||
**13 plan files closed.** The 8 the register named, plus `mesh-phase4` (Task 9 landed `3a590a0c`), the
|
||||
4th `stillpending` phase file, and `historian-tcp-transport` closed as **OBSOLETE** (it targets the
|
||||
retired Wonderware sidecar; there is no Wonderware backend in the tree). Each carries a `closureNote`
|
||||
naming the evidence, so the next reader can check rather than trust.
|
||||
|
||||
**11 archreview live gates written back** from `STATUS.md`, which had recorded them passed since
|
||||
2026-07-13/15 without ever updating the `.tasks.json` files: R2-01 #11, R2-02 #15/#18, R2-05 T15,
|
||||
R2-06 T12, R2-07 T5/T6/T12/T14, R2-11 T22/T24.
|
||||
|
||||
**R2-03 and R2-10 were NOT closed** — and reading `STATUS.md` rather than trusting its own headline
|
||||
("Every Round-2 live gate is now GREEN") is what kept that honest. Its per-item detail says R2-03 is
|
||||
"live-blocked on this data-less rig" and R2-10 verified the pipeline but "breaker-OPEN state not
|
||||
forced". Both now carry an `openNote` recording the blocker: R2-03 needs a reachable driver fixture
|
||||
feeding a VT; R2-10 needs a rapid idempotent write to a dead endpoint, because R2-09's
|
||||
`ConnectionBackoff` throttles retries so failures never accumulate fast enough to open the Polly breaker.
|
||||
|
||||
**Net effect (the point of the sweep):** ~140 phantom pending tasks are gone, so the genuinely
|
||||
unexecuted **19-task AdminUI follow-ups plan** is now the largest open item in `docs/plans/` rather than
|
||||
being buried. The remaining open plans are 8, and one of those (`mesh-phase6` Task 6) is a documented
|
||||
`resolved-no-flip` decision rather than work.
|
||||
|
||||
**Tier truth:** `docs/v2/driver-stability.md` now states plainly that its tier table is aspirational —
|
||||
every driver runs Tier A, and `MemoryRecycle` / `ScheduledRecycleScheduler` have never engaged. It also
|
||||
corrects a second stale claim the register did not flag: the **separate-Windows-service hosting** it
|
||||
describes for Tier C no longer exists (Galaxy went to the mxaccessgw sidecar in PR 7.2; FOCAS went
|
||||
in-process when its managed wire client landed). No runtime change — turning recycle on for two live
|
||||
drivers deserves its own live gate, not a docs-pass side effect. Tracked as Gitea **#522**.
|
||||
|
||||
### 2026-07-28 — the two things §8.3 deliberately did NOT build (1 of 2)
|
||||
|
||||
**Sql and FOCAS now re-parse in place too.** The first pass excluded them for a real reason — each derives
|
||||
more than options from config, so adopting new options alone would run a **new tag set against an old
|
||||
connection/backend**, and a half-applied change is worse than a discarded one because it looks like it
|
||||
worked. Rather than accept that, the blocker itself is fixed: each factory now exposes a **`ParseBinding`**
|
||||
that returns *every* config-derived dependency as one value, and the driver adopts them **atomically**.
|
||||
|
||||
| Driver | What one parse now yields | Adopted by |
|
||||
|---|---|---|
|
||||
| Sql | options + `ISqlDialect` + resolved connection string | `ApplyBinding`, which also rebuilds the provider factory, `Endpoint` and the `SqlPollReader` that captures all three |
|
||||
| FOCAS | options + the `IFocasClientFactory` the `Backend` key selects | one assignment pair in `InitializeAsync` |
|
||||
|
||||
Details worth keeping:
|
||||
|
||||
- **Sql adopts BEFORE `BuildTagTable`**, so the tag table and the connection it will be polled over always
|
||||
come from the same revision.
|
||||
- **A test-injected `DbProviderFactory` survives a rebind** (`_explicitFactory`), so a re-derived dialect
|
||||
cannot silently displace what a test passed in.
|
||||
- **Re-resolving the connection string on reinit is a side benefit**: a rotated credential is picked up
|
||||
without a process restart.
|
||||
- A driver constructed **directly** gets no rebinder and keeps its constructor-supplied binding — every
|
||||
existing `"{}"`-passing lifecycle test is unaffected by construction, not by luck.
|
||||
|
||||
**The tests pin ATOMICITY, not merely "a re-parse happened"** — a test that only checked options would have
|
||||
passed against the broken version. Verified by simulating the *half-fix* (adopt options, skip the backend):
|
||||
2 of the 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts
|
||||
that a reinit which **cannot resolve** its new connection leaves the previous binding whole, rather than
|
||||
half-adopting.
|
||||
|
||||
All 12 drivers now honour a changed config in place; `DriverSpawnPlanner`'s stop + respawn remains the outer
|
||||
guarantee. Sql.Tests 226 / FOCAS.Tests 275 pass.
|
||||
|
||||
### 2026-07-28 — the two things §8.3 deliberately did NOT build (2 of 2)
|
||||
|
||||
**The discovery-drift detector is built, and gated.** The original objection stands and is now *encoded*
|
||||
rather than used as a reason not to build: Modbus, S7, MQTT, AbLegacy and Sql echo authored config from
|
||||
`DiscoverAsync`, so a diff for them is a tautology. The missing piece was a discriminator — and the
|
||||
codebase already had one. `ITagDiscovery.SupportsOnlineDiscovery` is documented as "enumerates the tag set
|
||||
from the **live backend** rather than replaying pre-declared/authored tags", and it separates the fleet
|
||||
cleanly: **FOCAS, TwinCAT, MTConnect, AbCip** true; the five config-echo drivers explicitly false.
|
||||
|
||||
The check runs only for a driver that is `SupportsOnlineDiscovery` **and** reports the new
|
||||
`AuthoredDiscoveryRefs` (**nullable**, default null = opt out — an *empty* collection legitimately means
|
||||
"nothing authored, everything discovered is new", and conflating the two would report total drift for a
|
||||
driver that simply never opted in).
|
||||
|
||||
**Where the value is:** AbCip and FOCAS browse a live backend but implement **no `IRediscoverable`**, so
|
||||
before this they had *no* change signal at all — a periodic compare is the only way to notice a PLC
|
||||
re-download. TwinCAT and MTConnect already have native signals (symbol-version, agent instanceId); drift
|
||||
detection is complementary there.
|
||||
|
||||
**A finding that changed the design.** FOCAS, TwinCAT and AbCip all **re-emit their authored tags into the
|
||||
same `DiscoverAsync` stream** as the device-derived ones, so the browse picker can show an operator their
|
||||
existing tags. That means discovered ⊇ authored *always*, and a **vanished** tag can never appear missing
|
||||
— one whole direction is structurally undetectable for three of the four. Shipping a `Vanished` list that
|
||||
is permanently empty for them would have been exactly the half-inert seam this register exists to remove.
|
||||
So the driver declares `DiscoveryStreamIncludesAuthoredTags`, the detector does not compute the
|
||||
undetectable half, and the operator message **says** "missing-tag detection unavailable for this driver"
|
||||
rather than letting the absence of a missing-tag clause read as reassurance. **MTConnect is the only
|
||||
driver where both directions work** — its stream is purely probe-model-derived.
|
||||
|
||||
Behavioural details: a **failed browse is not drift** (an unreachable device would otherwise report every
|
||||
authored tag as vanished — the health surface already covers unreachability); a **truncated** capture is
|
||||
skipped for the same reason; an **unchanged** drift is reported once rather than re-stamping its timestamp
|
||||
every interval; drift that **resolves** clears the prompt so the next one is not deduped against a stale
|
||||
signature. Interval defaults to 5 minutes — it browses a real device, and a tag set changing is an
|
||||
engineering event, not a runtime one.
|
||||
|
||||
The comparison is a pure, separately-tested type (`TagSetDriftDetector`) rather than actor-inline logic.
|
||||
14 new tests. The gate was verified load-bearing by deleting the `SupportsOnlineDiscovery` half — the
|
||||
tautology-driver test goes red, and it asserts discovery was **never invoked** rather than merely "no
|
||||
prompt raised", which would have passed for the wrong reason if the timer simply never fired.
|
||||
|
||||
### 2026-07-27 — §8.4 dispatch-map parity (G-1 … G-6)
|
||||
|
||||
**The maps had to become data before they could be guarded.** A Razor `@switch` compiles into
|
||||
`BuildRenderTree`'s IL, so nothing can enumerate its cases — that is the whole reason G-1 and G-2
|
||||
survived review while the sibling picker guard stayed green (the picker test works only because
|
||||
`RawDriverTypeDialog` keeps its data in a *field* the markup enumerates). Both modals now render from
|
||||
`DriverConfigFormMap` / `DeviceFormMap` via `<DynamicComponent>`, and being `public` those maps need
|
||||
none of the picker test's `BindingFlags.NonPublic` fragility.
|
||||
|
||||
| Gap | Resolution |
|
||||
|---|---|
|
||||
| G-1 | `CalculationDriverForm.razor` added — `RunTimeout` was unauthorable through the UI. |
|
||||
| G-2 | Sql / MTConnect / Calculation are declared **single-connection** rather than given hollow device forms — each holds one connection at the driver level, so a per-device endpoint editor would be meaningless. |
|
||||
| G-3 | `DriverConfigModal`'s hardcoded `Galaxy or Mqtt` replaced by `DeviceFormMap.IsSingleConnection`. |
|
||||
| G-4 | `DriverFormMapParityTests` (5 cases, both directions) + `DriverDispatchMapParityTests` (3). |
|
||||
| G-5 | `CsvColumnMap` entries for Sql, Mqtt, MTConnect. |
|
||||
| G-6 | `RawBrowseCommitMapper` Sql branch — **and the browser end too**, which the register missed. |
|
||||
|
||||
**G-6 was bigger than filed.** The register said the mapper lacked a Sql branch. It also turned out that
|
||||
`SqlBrowseSession` emitted **no `AddressFields` at all**, so a browsed leaf carried only a column name —
|
||||
and a column name alone cannot address a Sql tag, which needs its table. The browser now travels
|
||||
schema/table/column and the mapper builds a `WideRow` `SqlTagConfigModel` from them. A branch alone
|
||||
would have produced a half-built blob.
|
||||
|
||||
**Two extra findings while writing the guards:**
|
||||
|
||||
- The G-6 test found **Modbus and Calculation** also fall through to the generic `{"address": …}` key.
|
||||
Both are correct — neither is browsable — so the test asserts the fall-through set **equals** a
|
||||
documented non-browsable list, in both directions. A one-way check would let a newly-browsable driver
|
||||
be quietly added to the exclusion list instead of getting a branch.
|
||||
- `TagConfigDriverTypeNameGuardTests` was **hollow**: it enumerated a hand-written `TheoryData` that had
|
||||
already drifted (omitting Galaxy, Sql and Mqtt), so it guarded against renames but not gaps — a new
|
||||
`DriverTypeNames` constant would simply never appear. Now enumerated from the map, paired with a
|
||||
coverage test in the other direction.
|
||||
|
||||
**Live-verified on docker-dev** (`localhost:9200`, central pair rebuilt), because this repo has no bUnit
|
||||
and no unit test can cover Blazor parameter binding: opened Calculation's config — the form renders where
|
||||
it previously showed "No typed config form" — typed `3500`, saved, reopened, and the value **persisted**,
|
||||
proving the `DynamicComponent` two-way binding round-trips. Sql's form renders fully (regression check on
|
||||
the conversion) and now reads "This driver holds a single connection, authored above" (G-3). The rebuilt
|
||||
image also showed `RediscoveryNeededUtc`/`RediscoveryReason` on the wire, confirming §8.2 end-to-end.
|
||||
|
||||
### 2026-07-27 — §8.3 #516 driver config edits silently discarded
|
||||
|
||||
Decision: **both** — per-driver re-parse *and* the seam respawn. Reading the drivers split the
|
||||
"per-driver" half in two, which the register did not anticipate:
|
||||
|
||||
- **Re-parse in place** (`Modbus`, `AbLegacy`, `OpcUaClient`) — `ParseOptions` extracted from each
|
||||
factory, called from `InitializeAsync` behind a `HasConfigBody` guard so `"{}"` still keeps the
|
||||
constructor options.
|
||||
- **Respawn-only, deliberately NOT re-parsed** (`Sql`, `FOCAS`) — each builds **more than options**
|
||||
from config: Sql's `ISqlDialect` + resolved connection string, FOCAS's client-factory backend, both
|
||||
injected at construction. Adopting new options alone would run a **new tag set against an old
|
||||
connection**. Half a re-parse is worse than none. Their doc-comments now say so.
|
||||
|
||||
**The seam respawn is the load-bearing half.** `DriverSpawnPlanner` now routes a changed
|
||||
`DriverConfig` to `ToStop` + `ToSpawn`, making the factory the single parse authority.
|
||||
`ToApplyDelta` is consequently always empty from the reconcile path. This **reverses the deliberate
|
||||
decision documented at `DriverSpawnPlan.cs:49-50`** ("a pure DriverConfig change stays an in-place
|
||||
delta — no reconnect"). That reasoning was right about resilience and wrong about the driver; the
|
||||
accepted price is a reconnect on every config edit.
|
||||
|
||||
Two seals removed from `ApplyChildDelta`: it overwrote the cached `Spec` **synchronously, before the
|
||||
child had dequeued the message**, so the host immediately believed the new config was live and the
|
||||
next reconcile computed no delta — sealing the drift permanently; and it `Tell`d with no
|
||||
`Receive<ApplyResult>` registered, so a failed reinit (including Galaxy's deliberate
|
||||
`NotSupportedException`) dead-lettered.
|
||||
|
||||
**A new visibility gap this change exposed, not created:** a factory throw is a *config* error
|
||||
(`TryCreate` is pure parsing; device I/O happens later), and `SpawnChild` catches it and silently
|
||||
substitutes a stub. Previously only a brand-new driver could hit it; now an ordinary config edit can.
|
||||
Raised from `Warning` to `Error` with an actionable message. It still does **not** fail the
|
||||
deployment — making it do so would let one malformed driver block a fleet deploy, so that is a
|
||||
deliberate follow-up rather than a drive-by change.
|
||||
|
||||
**Tests.** Every pre-existing reinit test in the five suites passes `"{}"` — precisely the input a
|
||||
guarded re-parser treats as "keep the constructor options", so they were blind to this defect *by
|
||||
construction*. The new tests pass **changed** JSON. The Modbus one was verified falsifiable by
|
||||
deleting the re-parse line (goes red). Two existing tests asserted the old behaviour and were
|
||||
rewritten: `DriverSpawnPlannerTests` (two cases), and
|
||||
`DriverHostActorUnreadableArtifactTests.Dropping_a_drivers_last_tag_does_clear_its_subscription` —
|
||||
a **positive control** for a sibling absence assertion, whose observable moved from `UnsubscribeAsync`
|
||||
to `ShutdownAsync` because the teardown now happens by stopping the child rather than emptying its
|
||||
desired set. Same event, different route; the control still calibrates the settle window.
|
||||
|
||||
Full-solution run: only pre-existing environment failures remain — `Host.IntegrationTests` (3,
|
||||
verified identical on the pre-change tree) and `Driver.AbLegacy.IntegrationTests` (4, docker
|
||||
fixture-gated, and notably these **fail rather than skip**, unlike every other fixture-gated suite).
|
||||
|
||||
---
|
||||
|
||||
*Generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
|
||||
Every `path:line` reference was read from source. Items marked CANNOT VERIFY require hardware, a
|
||||
VPN'd gateway, or a live rig and are listed as unproven rather than assumed.*
|
||||
@@ -1,5 +1,27 @@
|
||||
# Address Space
|
||||
|
||||
> ⚠️ **Accuracy warning (audited 2026-07-27) — this page is v2-era and contradicts the shipped v3
|
||||
> address space.** Corrections, in order of how badly they mislead:
|
||||
>
|
||||
> - **There is no "single custom namespace".** v3 exposes **two**:
|
||||
> `https://zb.com/otopcua/raw` (`s=<RawPath>`) and `https://zb.com/otopcua/uns`
|
||||
> (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`), registered together at
|
||||
> `OtOpcUaNodeManager.cs:45`. Identity authority is `V3NodeIds` + `AddressSpaceRealm`. The retired
|
||||
> `https://zb.com/otopcua/ns` survives only in a comment marking it retired (`V3NodeIds.cs:17`).
|
||||
> - **`Phase7Applier` / `Phase7Composer` / `GalaxyTagPlan` do not exist**, nor do the file paths cited
|
||||
> for them. Renamed by `40e8a23e` to `AddressSpaceApplier` / `AddressSpaceComposer`
|
||||
> (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`); the composition result type is
|
||||
> `AddressSpaceComposition` (`AddressSpaceComposer.cs:13`).
|
||||
> - **The `SystemPlatform` namespace kind is retired** — Galaxy is a standard Equipment-kind driver.
|
||||
> - **`GenericDriverNodeManager` is test scaffolding**, not a production dispatch path
|
||||
> (`GenericDriverNodeManager.cs:71`).
|
||||
> - **The §Rediscovery section is wrong**: nothing consumes `OnRediscoveryNeeded`
|
||||
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)), and the driver list is stale
|
||||
> (MQTT and MTConnect now raise it too).
|
||||
>
|
||||
> For the current design see the "v3 OPC UA Address Space (Batch 4)" section of `CLAUDE.md`,
|
||||
> `docs/Raw.md`, and `docs/Uns.md`.
|
||||
|
||||
Address-space construction is a two-layer system. The **driver-facing layer** is the streaming builder: a driver implements `ITagDiscovery.DiscoverAsync` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`) and emits `Folder` / `Variable` / `AddProperty` calls into an `IAddressSpaceBuilder` as it walks its backend — no buffering of the whole tree. `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) wraps that builder to capture alarm-condition sinks and routes alarm events from the driver to them. The **SDK materialization layer** turns the resulting node descriptions into live OPC UA nodes: `OpcUaPublishActor` drives the write-only `IOpcUaAddressSpaceSink`, whose production binding `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) forwards to `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`), a `CustomNodeManager2` subclass that owns the `FolderState` / `BaseDataVariableState` instances. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
|
||||
|
||||
## Root folder
|
||||
|
||||
@@ -114,6 +114,21 @@ source it from there.
|
||||
> hardcodes OPC-DA Good, `192`). Capturing real per-node quality will not persist until the gateway honors
|
||||
> the field — see `GatewayHistorianValueWriter`'s remarks (archreview 06/C-7).
|
||||
|
||||
> ⚠️ **Continuous value capture is code-complete but UNPROVEN IN PRODUCTION
|
||||
> ([#491](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/491)).** The recorder, the FasterLog
|
||||
> outbox, the gateway value-writer and the deploy-time historized-ref feed
|
||||
> (`AddressSpaceApplier.FeedHistorizedRefs` → `ActorHistorizedTagSubscriptionSink` →
|
||||
> `ContinuousHistorizationRecorder.UpdateHistorizedRefs`) are all wired, and the recorder converges to
|
||||
> the deployed ref set from the first deploy onward. What has **not** run is the end-to-end live gate
|
||||
> (deploy → dependency-mux value delta → outbox append → `WriteLiveValues`) plus a restart-convergence
|
||||
> test. **Reads and alarm-writes are live-validated; treat value capture as unproven rather than
|
||||
> absent.** See the "KNOWN LIMITATION 2" section of `CLAUDE.md`.
|
||||
>
|
||||
> **Related, separately open:** `EnsureTags` provisioning fires only for **newly-added** historized
|
||||
> tags (`AddressSpaceApplier` iterates the added set), so flipping an **existing** tag to
|
||||
> `"isHistorized": true` never provisions it — see
|
||||
> `docs/plans/2026-06-27-historian-gateway-integration-issues.md` Issue 5.
|
||||
|
||||
---
|
||||
|
||||
## Tag auto-provisioning (EnsureTags)
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Incremental Sync
|
||||
|
||||
> ⚠️ **Accuracy warning (audited 2026-07-27) — most of this page describes machinery that is retired
|
||||
> or was never wired.** Read it as v2-era design history, not as current behaviour.
|
||||
>
|
||||
> - **"Rebuild flow" (below) is not a production path.** It is built on `GenericDriverNodeManager`,
|
||||
> which is Core test scaffolding with **zero production references**
|
||||
> (`GenericDriverNodeManager.cs:71`). Its `_variablesByFullRef` / `_sourceByFullRef` fields do not
|
||||
> exist in `src/`.
|
||||
> - **Driver-backend rediscovery has no consumer.** Nothing in `src/Server/` or `src/Core/`
|
||||
> subscribes to `IRediscoverable.OnRediscoveryNeeded`; `DriverHostActor.HandleDiscoveredNodes`
|
||||
> (`DriverHostActor.cs:986-1002`) short-circuits unconditionally. The `ScopeHint` is read by
|
||||
> nothing. Drivers that raise it today: Galaxy, TwinCAT, MQTT/Sparkplug, MTConnect
|
||||
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
|
||||
> [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)).
|
||||
> - **The generation-publish path is retired.** `sp_PublishGeneration` and
|
||||
> `sp_ComputeGenerationDiff` are stubs that `RAISERROR`
|
||||
> (`20260715230637_V3Initial.StoredProcedures.cs:194`); `DraftRevisionToken` does not exist.
|
||||
>
|
||||
> **The one live rebuild path in v3** is the deploy artifact: `ConfigComposer` →
|
||||
> `Deployment.ArtifactBlob` → `DriverHostActor` apply → `AddressSpaceComposer` /
|
||||
> `AddressSpaceApplier` diff-apply.
|
||||
|
||||
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
|
||||
|
||||
## Driver-backend rediscovery — IRediscoverable
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ In v2 the Server and Admin processes were fused into a single role-gated `ZB.MOM
|
||||
- `CreateMasterNodeManager` constructs one `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) — a `CustomNodeManager2` subclass that owns the writable address space under the namespace `https://zb.com/otopcua/ns` and a single `OtOpcUa` root folder organized under the standard `Objects` folder. It is wrapped in a `MasterNodeManager` with no additional core managers.
|
||||
- `OtOpcUaSdkServer.NodeManager` exposes the live node manager after `StartAsync`, so the hosting layer can wrap it in a `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) and hand it to `OpcUaPublishActor`.
|
||||
|
||||
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (`DriverInstanceActor` → `OpcUaPublishActor`), and `OpcUaPublishActor` writes them into the node manager through the `IOpcUaAddressSpaceSink` seam. `OtOpcUaNodeManager.EnsureFolder` / `EnsureVariable` materialize the UNS folder + variable hierarchy; `WriteValue` / `WriteAlarmState` push runtime values and fire `ClearChangeMasks` so subscribed clients see updates.
|
||||
Address-space population is push-driven: drivers stream discovery and data-change events through the Akka actor system (`DriverInstanceActor` → `OpcUaPublishActor`), and `OpcUaPublishActor` writes them into the node manager through the `IOpcUaAddressSpaceSink` seam. `OtOpcUaNodeManager.EnsureFolder` / `EnsureVariable` materialize the UNS folder + variable hierarchy; `WriteValue` / `WriteAlarmCondition` push runtime values and fire `ClearChangeMasks` so subscribed clients see updates.
|
||||
|
||||
The driver-agnostic walk that turns a driver's discovery into folder/variable calls lives in `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`): it walks `ITagDiscovery.DiscoverAsync` into an `IAddressSpaceBuilder`, captures alarm-condition sinks for variables flagged via `IVariableHandle.MarkAsAlarmCondition`, subscribes to `IAlarmSource.OnAlarmEvent`, and routes each alarm transition to the sink registered for its `SourceNodeId`.
|
||||
|
||||
@@ -48,7 +48,7 @@ The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpc
|
||||
|
||||
`OpcUaApplicationHost` subscribes to `SessionManager.ImpersonateUser` after `ApplicationInstance.Start`. The handler (`HandleImpersonation`) deals with the token types as follows:
|
||||
|
||||
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
|
||||
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapOpcUaUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
|
||||
- `AnonymousIdentityToken` and X.509 tokens → the handler returns without intervening, so the SDK's default validation stands.
|
||||
|
||||
Decryption failures and authenticator exceptions also map to `BadIdentityTokenRejected`.
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
| [Configuration.md](v1/Configuration.md) | appsettings bootstrap + Config DB + Admin UI draft/publish (v1 archive — `OTOPCUA_GALAXY_*` env vars now live in mxaccessgw config) |
|
||||
| [Uns.md](Uns.md) | The global `/uns` master tree — manage Area/Line/Equipment/Tag/VirtualTag fleet-wide; replaces the per-cluster UNS/Equipment/Tags tabs |
|
||||
| [security.md](security.md) | Transport security profiles, LDAP auth, ACL trie, role grants, OTOPCUA0001 analyzer |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyCoordinator`, `ServiceLevelCalculator`, apply-lease, Prometheus metrics |
|
||||
| [Redundancy.md](Redundancy.md) | `RedundancyStateActor` (cluster-scoped singleton), `ServiceLevelCalculator`, `IRedundancyRoleView`, per-cluster meshes, Prometheus metrics |
|
||||
| [Reservations.md](Reservations.md) | Fleet-wide ZTag / SAPID external-ID reservations — publish-time claim, release flow |
|
||||
| [ServiceHosting.md](ServiceHosting.md) | Single fused `OtOpcUa.Host` binary install/uninstall with `OTOPCUA_ROLES` gating; the historian backend is the external HistorianGateway |
|
||||
| [StatusDashboard.md](StatusDashboard.md) | Pointer — superseded by [v2/admin-ui.md](v2/admin-ui.md) |
|
||||
|
||||
+96
-43
@@ -1,67 +1,120 @@
|
||||
# Read/Write Operations
|
||||
|
||||
The v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value.
|
||||
> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`.
|
||||
> The previous revision described a v2-era `DriverNodeManager` with per-variable `OnReadValue` hooks,
|
||||
> an `AuthorizationGate`/`WriteAuthzPolicy` ACL pair and a `NodeSourceKind` dispatch switch. **None of
|
||||
> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef`
|
||||
> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing
|
||||
> like the way it was documented. See `deferment.md` §3.1 and §7.
|
||||
|
||||
## Driver vs virtual dispatch
|
||||
## The shape of it: push for reads, pull for writes
|
||||
|
||||
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend:
|
||||
The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the
|
||||
single most important thing on this page:
|
||||
|
||||
- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc).
|
||||
- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.
|
||||
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim.
|
||||
- **Reads never reach a driver.** There is no `Read` override and no `OnReadValue` / `OnSimpleReadValue`
|
||||
handler anywhere in the node manager. Driver values are *pushed in* from the Akka actor layer
|
||||
(`DriverInstanceActor` polls or subscribes, `DriverHostActor` fans the value to the raw NodeId and
|
||||
every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the
|
||||
cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail
|
||||
with a device error — it returns whatever quality was last pushed.
|
||||
- **Writes are synchronous pull-through.** A client write runs `OnWriteValue` → role gate → driver.
|
||||
|
||||
ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.
|
||||
Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the
|
||||
invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The
|
||||
`OpcUaServer` project does not reference it at all.
|
||||
|
||||
## OnReadValue
|
||||
## Read path
|
||||
|
||||
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
|
||||
1. The SDK resolves the NodeId in the Raw (`ns=2`) or UNS (`ns=3`) namespace.
|
||||
2. It returns the cached `DataValue` — value, `StatusCode` and source timestamp as last pushed.
|
||||
3. **No authorization check runs.** Not a per-node ACL, not a role check, nothing. Any admitted
|
||||
session — including an Anonymous one — can read every node in the address space. The only gating is
|
||||
the `AccessLevels` bitmask set at materialization, which is a per-node *capability* declaration, not
|
||||
a per-user decision.
|
||||
|
||||
1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`.
|
||||
2. The node's `NodeId.Identifier` is used directly as the driver-side full reference — it matches `DriverAttributeInfo.FullName` registered at discovery time.
|
||||
3. (Phase 6.2) If an `AuthorizationGate` + `NodeScopeResolver` are wired, the gate is consulted first via `IsAllowed(identity, OpcUaOperation.Read, scope)`. A denied read never hits the driver.
|
||||
4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144).
|
||||
5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`.
|
||||
Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see
|
||||
[docs/security.md](security.md) § Data-Plane Authorization).
|
||||
|
||||
The hook is synchronous — the async invoker call is bridged with `AsTask().GetAwaiter().GetResult()` because the OPC UA SDK's value-hook signature is sync. Idempotent-by-construction reads mean this bridge is safe to retry inside the Polly pipeline.
|
||||
## Write path — `OnEquipmentTagWrite`
|
||||
|
||||
## OnWriteValue
|
||||
The handler is attached in `EnsureVariable` **only when the tag was materialized `writable`**, and
|
||||
re-attached or cleared by `UpdateTagAttributes` on an in-place edit. A non-writable node has no
|
||||
handler, so the SDK rejects the write before any of this runs.
|
||||
|
||||
`OnWriteValue` follows the same shape with two additional concerns: authorization and idempotence.
|
||||
1. **Role gate.** `EvaluateEquipmentWriteGate(identity, gatewayWired)` requires the session identity to
|
||||
be a `RoleCarryingUserIdentity` carrying the `WriteOperate` role. No identity or no role ⇒
|
||||
`BadUserAccessDenied`, fail-closed. Gate passed but no write gateway wired (admin-only node,
|
||||
pre-boot) ⇒ `BadNotWritable`.
|
||||
- This is a **server-wide** check. It takes no node and no realm: a session that may write one tag
|
||||
may write every writable tag on that node.
|
||||
- `WriteTune` and `WriteConfigure` are **never checked**. `OpcUaDataPlaneRoles` declares only
|
||||
`WriteOperate` and `AlarmAck`; the classification tiers exist in the permission vocabulary but no
|
||||
code path reads them.
|
||||
2. **Optimistic local apply.** The new value is written to the node so subscribers see it immediately.
|
||||
3. **Realm-qualified dispatch.** `RealmOf(node.NodeId)` selects Raw or UNS, and the value routes to the
|
||||
backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the
|
||||
same driver ref, so a write via either one reaches the same device point.
|
||||
4. **Write-outcome self-correction.** If the device write fails, the optimistic value is **reverted**
|
||||
on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write
|
||||
cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so
|
||||
it can never surface a write failure.)
|
||||
|
||||
### Authorization (two layers)
|
||||
`OnWriteValue` is invoked by the SDK **while holding the node-manager `Lock`**, so the handler must not
|
||||
block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation.
|
||||
|
||||
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`).
|
||||
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
|
||||
## Alarm method calls
|
||||
|
||||
### Dispatch
|
||||
|
||||
`_invoker.ExecuteWriteAsync(host, isIdempotent, callSite, …)` honors the `WriteIdempotentAttribute` semantics per decisions #44-45 and #143:
|
||||
|
||||
- `isIdempotent = true` (tag flagged `WriteIdempotent` in the Config DB) → runs through the standard `DriverCapability.Write` pipeline; retry may apply per the tier configuration.
|
||||
- `isIdempotent = false` (default) → the invoker builds a one-off pipeline with `RetryCount = 0`. A timeout may fire after the device already accepted the pulse / alarm-ack / counter-increment; replay is the caller's decision, not the server's.
|
||||
|
||||
The `_writeIdempotentByFullRef` lookup is populated at discovery time from the `DriverAttributeInfo.WriteIdempotent` field.
|
||||
|
||||
### Per-write status
|
||||
|
||||
`IWritable.WriteAsync` returns `IReadOnlyList<WriteResult>` — one numeric `StatusCode` per requested write. A non-zero code is surfaced directly to the client; exceptions become `BadInternalError`. The OPC UA stack's pattern of batching per-service is preserved through the full chain.
|
||||
|
||||
## Array element writes
|
||||
|
||||
Array-element writes via OPC UA `IndexRange` are driver-specific. The OPC UA stack hands the dispatch an unwrapped `NumericRange` on the `indexRange` parameter of `OnWriteValue`; `DriverNodeManager` passes the full `value` object to `IWritable.WriteAsync` and the driver decides whether to support partial writes. Galaxy performs a read-modify-write inside the Galaxy driver (MXAccess has no element-level writes); other drivers generally accept only full-array writes today.
|
||||
Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
|
||||
are gated by a second role check requiring `AlarmAck` — one role covering all five methods; the
|
||||
`operation` argument is passed to the router but the gate does not consult it. Scripted alarms go
|
||||
through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed
|
||||
with `BadUserAccessDenied`.
|
||||
|
||||
## HistoryRead
|
||||
|
||||
`DriverNodeManager.HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`, and `HistoryReadEvents` route through the driver's `IHistoryProvider` capability with `DriverCapability.HistoryRead`. Drivers without `IHistoryProvider` surface `BadHistoryOperationUnsupported` per node. See `docs/v1/HistoricalDataAccess.md`.
|
||||
Four overrides — `HistoryReadRawModified`, `HistoryReadProcessed`, `HistoryReadAtTime`,
|
||||
`HistoryReadEvents` — route to the **server-wide** `IHistorianDataSource` (the HistorianGateway-backed
|
||||
reader, or `NullHistorianDataSource` when `ServerHistorian:Enabled=false`, which returns `GoodNoData`).
|
||||
This is not a per-driver `IHistoryProvider` dispatch.
|
||||
|
||||
**No authorization check runs on any of the four.** Unlike `OnWriteValue`, the SDK does *not* hold the
|
||||
node-manager `Lock` while invoking them, so these paths may await.
|
||||
|
||||
See [docs/Historian.md](Historian.md).
|
||||
|
||||
## Failure isolation
|
||||
|
||||
Per decision #12, exceptions in the driver's capability call are logged and converted to a per-node `BadInternalError` — they never unwind into the master node manager. This keeps one driver's outage from disrupting sibling drivers in the same server process.
|
||||
Exceptions in a driver capability call are logged and converted to a per-node bad status rather than
|
||||
unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the
|
||||
same process.
|
||||
|
||||
## What is *not* enforced anywhere
|
||||
|
||||
Collected here because the previous revision of this page claimed several of these:
|
||||
|
||||
| Surface | Check |
|
||||
|---|---|
|
||||
| Read, Browse, TranslateBrowsePaths | none |
|
||||
| CreateMonitoredItems / Subscribe / TransferSubscriptions | none |
|
||||
| HistoryRead (all four variants) | none |
|
||||
| Non-Value attribute writes | none (the handler is on `OnWriteValue`) |
|
||||
| Value write | `WriteOperate` role, server-wide |
|
||||
| Alarm methods | `AlarmAck` role, server-wide |
|
||||
| Per-node ACLs (`NodeAcl` / `PermissionTrie`) | **authored and deployed, never evaluated** |
|
||||
|
||||
## Key source files
|
||||
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — address-space population and alarm routing during discovery
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — permission trie + evaluator (`PermissionTrie`, `PermissionTrieCache`, `TriePermissionEvaluator`) that gates Read/Write/Subscribe per the session's resolved LDAP groups
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync`
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.cs`
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — the live node manager;
|
||||
`EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four
|
||||
HistoryRead overrides
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`,
|
||||
`Security/OpcUaDataPlaneRoles.cs` — how roles reach the gates
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — materialization; where the write
|
||||
handler is attached per realm
|
||||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — the push side: value fan-out to
|
||||
raw + UNS NodeIds
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — driver-side resilience pipeline
|
||||
(**not** on the OPC UA read path)
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` — the permission trie + evaluator, built and
|
||||
unit-tested but with **zero production consumers**
|
||||
|
||||
@@ -70,7 +70,7 @@ Project root files:
|
||||
| Capability | Implementation entry point |
|
||||
|------------|---------------------------|
|
||||
| `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` |
|
||||
| `IRediscoverable` | `Browse/DeployWatcher.cs` |
|
||||
| `IRediscoverable` | `Browse/DeployWatcher.cs` — consumed as an **operator prompt**: a Galaxy redeploy raises a "re-browse" chip on `/hosts`. ⚠️ It does **not** rebuild the address space — re-browse the device under `/raw` and commit ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)) |
|
||||
| `IReadable` | `Runtime/GalaxyMxSession.cs` |
|
||||
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
|
||||
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) |
|
||||
|
||||
+17
-12
@@ -220,8 +220,9 @@ what the driver's cursor expects) is detected **two ways**, both triggering a
|
||||
**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.
|
||||
`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI
|
||||
`/hosts` page — see [Known limitations](#known-limitations) for what that does
|
||||
and does not do.
|
||||
|
||||
## Browse — free via the universal browser
|
||||
|
||||
@@ -249,16 +250,20 @@ need re-authoring. See [Deferred](#deferred-not-in-this-build).
|
||||
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.
|
||||
1. **A restarted Agent prompts an operator; it does not re-shape the address
|
||||
space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded`
|
||||
(2026-07-27), so a changed `instanceId` raises a "re-browse" chip against
|
||||
this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors
|
||||
raw tags through the `/raw` browse-commit flow, so until an operator
|
||||
re-browses the Agent and commits, any DataItem that appeared or vanished is
|
||||
not reflected in the served tree. A runtime graft was deliberately rejected
|
||||
— it would materialise nodes nobody approved and no deployment artifact
|
||||
records.
|
||||
|
||||
**`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has
|
||||
no production call site and nothing writes a `DriverHostStatus` row. That
|
||||
half remains a fleet-wide gap affecting every driver that implements it
|
||||
(Gitea #521).
|
||||
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
|
||||
routing.** One driver instance owns exactly one Agent client scoped by
|
||||
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
|
||||
|
||||
@@ -124,7 +124,7 @@ ConditionType events (non-base `BaseEventType`) is not verified.
|
||||
- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested
|
||||
but not exchanged against a server that actually enforces each.
|
||||
- LDAP-backed `UserName` (matching this repo's server-side
|
||||
`LdapUserAuthenticator`) requires a live LDAP round-trip; not tested.
|
||||
`LdapOpcUaUserAuthenticator`) requires a live LDAP round-trip; not tested.
|
||||
|
||||
## When to trust OpcUaClient tests, when to reach for a server
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Drivers
|
||||
|
||||
OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `Core.Abstractions` + `Server`) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and namespace kinds (Equipment + SystemPlatform). Drivers plug in through **capability interfaces** defined in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
|
||||
OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `Core.Abstractions` + `Server`) owns the OPC UA stack, address space, session/security/subscription machinery, resilience pipeline, and the two v3 OPC UA namespaces (Raw + UNS — see `V3NodeIds` / `AddressSpaceRealm`). Drivers plug in through **capability interfaces** defined in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/`:
|
||||
|
||||
- `IDriver` — lifecycle (`InitializeAsync`, `ReinitializeAsync`, `ShutdownAsync`, `GetHealth`)
|
||||
- `IReadable` / `IWritable` — one-shot reads and writes
|
||||
@@ -15,14 +15,18 @@ OtOpcUa is a multi-driver OPC UA server. The Core (`ZB.MOM.WW.OtOpcUa.Core` + `C
|
||||
|
||||
Each driver opts into only the capabilities it supports. Every async capability call at the Server dispatch layer goes through `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`), which wraps it in a Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability)`. The `OTOPCUA0001` analyzer enforces the wrap at build time. Drivers themselves never depend on Polly; they just implement the capability interface and let the Core wrap it.
|
||||
|
||||
Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs`). The registry records each type's allowed namespace kinds (`Equipment` / `SystemPlatform` / `Simulated`), its JSON Schema for `DriverConfig` / `DeviceConfig` / `TagConfig` columns, and its stability tier per [docs/v2/driver-stability.md](../v2/driver-stability.md).
|
||||
Driver **factories** are registered at startup in `DriverFactoryRegistry` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs`) — all 12 in one place, `src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:147-164`. Type names are the `DriverTypeNames` constants (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`), guarded bidirectionally by `DriverTypeNamesGuardTests`.
|
||||
|
||||
> ⚠️ **`DriverTypeRegistry` / `DriverTypeMetadata` are vestigial.** No production code constructs or reads them — the class is referenced only by its own unit tests. Its `AllowedNamespaceKinds` field was retired along with the v3 `Namespace` entity (`DriverTypeRegistry.cs:97`), and the `SystemPlatform` namespace kind no longer exists.
|
||||
>
|
||||
> **Stability tier** likewise does not come from that registry: it is the `DriverTier tier = DriverTier.A` parameter on `DriverFactoryRegistry.cs:46`, and **no factory in `src/Drivers/` passes a tier**, so every shipped driver runs Tier A. The Tier-C-only protections described in [docs/v2/driver-stability.md](../v2/driver-stability.md) — `MemoryRecycle` hard-breach kill (`MemoryRecycle.cs:54`) and `ScheduledRecycleScheduler` (`:43`) — are therefore dormant for every driver.
|
||||
|
||||
## Ground-truth driver list
|
||||
|
||||
| Driver | Project path | Tier | Wire / library | Capabilities | Notable quirk |
|
||||
|--------|--------------|:----:|----------------|--------------|---------------|
|
||||
| [Galaxy](Galaxy.md) | `Driver.Galaxy` (+ `.Browser`, `.Contracts`) | A | gRPC to the external `mxaccessgw` gateway (the gateway owns MXAccess COM + the Galaxy Repository SQL reader) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IRediscoverable, IHostConnectivityProbe | In-process .NET 10 driver — the COM bitness constraint lives in the gateway's x86 net48 worker, not here. PR 7.2 retired the legacy in-process `Galaxy.{Shared, Host, Proxy}` + named-pipe Windows service. Native MxAccess alarms work end-to-end |
|
||||
| [Modbus TCP](Modbus.md) | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
|
||||
| [Modbus TCP / RTU-over-TCP](Modbus.md) | `Driver.Modbus` (+ `.Addressing`, `.Contracts`) | A | NModbus-derived in-house client; `Transport` selects MBAP (TCP) or RTU CRC-16 framing over a socket | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver. `Transport=RtuOverTcp` (`ModbusTransportMode`, default `Tcp`) targets serial→Ethernet gateways via `ModbusRtuOverTcpTransport` + `ModbusRtuFraming` + `ModbusCrc`, with `ModbusTransportDesyncException` carrying distinct CrcMismatch/UnitMismatch reasons; **direct-serial transport is descoped**. Merged `0f38f486` (#495) |
|
||||
| [Siemens S7](S7.md) | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
|
||||
| [AB CIP](AbCip.md) | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
||||
| [AB Legacy](AbLegacy.md) | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
||||
@@ -31,6 +35,8 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/Core
|
||||
| [OPC UA Client](OpcUaClient.md) | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver — the only driver implementing driver-side `IHistoryProvider` (forwards HistoryRead to the upstream server). Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
|
||||
| [MQTT](Mqtt.md) | `Driver.Mqtt` (+ `.Browser`, `.Contracts`) | A | [MQTTnet 5](https://github.com/dotnet/MQTTnet) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | **Push, not poll, and read-only** — the broker delivers PUBLISH frames the driver forwards straight to `ISubscribable.OnDataChange`; `IReadable` serves the last retained/received value from cache, and there is **no `IWritable`** (nothing publishes back). Subscriptions are indexed **by topic filter, not by topic**, so wildcard tags survive a reconnect. A rejected CONNACK throws `MqttConnectRejectedException`: unrecoverable codes → `Faulted` + supervisor stop, transient → retry. **Two modes:** `Plain` binds a tag to a concrete topic + JSON path; `SparkplugB` decodes vendored Eclipse-Tahu protobuf under one `spBv1.0/{groupId}/#` subscription and binds by the `group/edgeNode/device?/metric` tuple (birth/alias/seq-gap state machine, death→STALE, scoped rebirth NCMD). `IRediscoverable` fires on a DBIRTH but is **inert platform-side** — see [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) |
|
||||
| [MTConnect](MTConnect.md) | `Driver.MTConnect` (+ `.Contracts`) | — | Hand-rolled `System.Xml.Linq` HTTP/XML client against a vendor-neutral MTConnect Agent (`/probe`, `/current`, `/sample`) — no TrakHound dependency (dropped; see the driver doc) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable | Read-only by design (no `IWritable`). Production data plane is entirely `ISubscribable`/`OnDataChange` — `IReadable` is CLI/test-only. Browse is free via the Wave-0 universal discovery browser, no bespoke browser project. `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer yet — a pre-existing fleet-wide gap, not MTConnect-specific |
|
||||
| **Sql** (no dedicated page — see [design](../plans/2026-07-15-sql-poll-driver-design.md)) | `Driver.Sql` (+ `.Browser`, `.Contracts`) | A | `Microsoft.Data.SqlClient` behind an `ISqlDialect` seam (`SqlServerDialect` shipped; Postgres/ODBC deferred) | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe | Read-only DB-poll driver with a bespoke schema browser. **Credentials are never persisted** — the deploy gate rejects a stored `connectionString` (#498) and catalog identifiers are validated against the live catalog's own spelling (#496). `SupportsOnlineDiscovery => false` by design (the bespoke browser wins). `SqlTagModel.Query` is deferred to P3 and rejected end-to-end by parser, validator, planner and editor. Merged `4ad54037` |
|
||||
| **Calculation** (see [Raw.md](../Raw.md#the-calculation-driver)) | `Driver.Calculation` | A | none — computes from other tags' RawPaths | IDriver, IDependencyConsumer, ISubscribable, IReadable | **Pseudo-driver**, not a wire protocol: signal-level tags computed from other tags via a script, with deploy-time `scriptId`-existence + Tarjan cycle gates. Its probe is parse-only (no backend to reach). ⚠️ **No typed config form** — `DriverConfigModal` has no `Calculation` case, so `RunTimeout` is currently unauthorable through the AdminUI |
|
||||
| [Historian.Gateway](../Historian.md) | `Driver.Historian.Gateway` | — | `ZB.MOM.WW.HistorianGateway.Client` gRPC (`historian_gateway.v1`) | IHistorianDataSource (server-side read backend) + alarm `SendEvent` writer + `WriteLiveValues` recorder + `IHistorianProvisioning` | Not a tag driver — the sole historian backend. Registers `GatewayHistorianDataSource : IHistorianDataSource` for HistoryRead and serves alarm-write + continuous historization through the gateway. No `IDriver`/`ITagDiscovery` surface. (The retired Wonderware sidecar backend it replaced is documented at [Historian.Wonderware.md](Historian.Wonderware.md).) |
|
||||
|
||||
## Per-driver documentation
|
||||
|
||||
+11
-4
@@ -92,7 +92,7 @@ discovery.
|
||||
| `ISubscribable` | native ADS notifications (default), poll fallback | `UseNativeNotifications=true` registers device notifications so the PLC pushes changes; `false` uses the shared `PollGroupEngine` |
|
||||
| `IHostConnectivityProbe` | per-device probe loop | One `HostConnectivityStatus` per configured device; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
|
||||
| `IPerCallHostResolver` | `ResolveHost` lookup in the tag map | Routes each call to the device of the referenced tag; returns an empty-string sentinel when unresolved |
|
||||
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded` so the address space is rebuilt |
|
||||
| `IRediscoverable` | symbol-version-changed callback | A PLC re-download fires `OnRediscoveryNeeded`. ⚠️ **No consumer wires it today** ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) — the address space does **not** rebuild; a config redeploy is required. Driver-side behaviour is log-observable only |
|
||||
|
||||
### Rediscovery on PLC re-download
|
||||
|
||||
@@ -100,8 +100,15 @@ discovery.
|
||||
`DeviceSymbolVersionInvalid` (1809 / 0x0711) — the documented TwinCAT
|
||||
symbol-version-changed signal, raised when a PLC program is re-downloaded —
|
||||
every symbol and notification handle is invalidated. The driver raises
|
||||
`OnRediscoveryNeeded` with a `TwinCAT` scope hint so Core rebuilds the address
|
||||
space rather than treating it as a transient connection error.
|
||||
`OnRediscoveryNeeded` with a `TwinCAT` scope hint, so that the signal is treated
|
||||
as an address-space change rather than a transient connection error.
|
||||
|
||||
⚠️ **That is the intended design, not present behaviour.** No consumer subscribes
|
||||
to `OnRediscoveryNeeded` anywhere in `src/`, and the scope hint is read by nothing
|
||||
([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
|
||||
[#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)). Today the
|
||||
signal is observable only in the driver log; after a PLC re-download the served
|
||||
address space changes only on a config redeploy.
|
||||
|
||||
### Native notifications
|
||||
|
||||
@@ -148,7 +155,7 @@ fixture is down during normal dev.
|
||||
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
|
||||
|
||||
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
|
||||
arrays, per-element historization. `IRediscoverable` still fires and rebuilds array nodes
|
||||
arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518)
|
||||
on PLC re-download (unchanged semantics from scalar nodes).
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
|
||||
@@ -1,18 +1,116 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-12-historian-tcp-transport.md",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeTaskId": 296, "subject": "Task 0: Create feature branch", "status": "pending"},
|
||||
{"id": 1, "nativeTaskId": 297, "subject": "Task 1: Add TCP/TLS fields to client options", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "nativeTaskId": 298, "subject": "Task 2: Client TCP connect factory + FrameChannel rename", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeTaskId": 299, "subject": "Task 3: Switch client default ctor to TCP", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "nativeTaskId": 300, "subject": "Task 4: Host config binding (Host/Port/TLS)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "nativeTaskId": 301, "subject": "Task 5: Sidecar TcpFrameServer", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 6, "nativeTaskId": 302, "subject": "Task 6: Sidecar Program.cs — TCP bootstrap + env", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeTaskId": 303, "subject": "Task 7: Remove dead pipe code + finalize options shape", "status": "pending", "blockedBy": [3, 4, 6]},
|
||||
{"id": 8, "nativeTaskId": 304, "subject": "Task 8: Deploy scripts — env block + firewall + cert", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "nativeTaskId": 305, "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 10, "nativeTaskId": 306, "subject": "Task 10: Docs — TCP transport", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 11, "nativeTaskId": 307, "subject": "Task 11: Verification (build + test + live)", "status": "pending", "blockedBy": [8, 9, 10]}
|
||||
{
|
||||
"id": 0,
|
||||
"nativeTaskId": 296,
|
||||
"subject": "Task 0: Create feature branch",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"nativeTaskId": 297,
|
||||
"subject": "Task 1: Add TCP/TLS fields to client options",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"nativeTaskId": 298,
|
||||
"subject": "Task 2: Client TCP connect factory + FrameChannel rename",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"nativeTaskId": 299,
|
||||
"subject": "Task 3: Switch client default ctor to TCP",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"nativeTaskId": 300,
|
||||
"subject": "Task 4: Host config binding (Host/Port/TLS)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"nativeTaskId": 301,
|
||||
"subject": "Task 5: Sidecar TcpFrameServer",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"nativeTaskId": 302,
|
||||
"subject": "Task 6: Sidecar Program.cs \u2014 TCP bootstrap + env",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"nativeTaskId": 303,
|
||||
"subject": "Task 7: Remove dead pipe code + finalize options shape",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3,
|
||||
4,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"nativeTaskId": 304,
|
||||
"subject": "Task 8: Deploy scripts \u2014 env block + firewall + cert",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"nativeTaskId": 305,
|
||||
"subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"nativeTaskId": 306,
|
||||
"subject": "Task 10: Docs \u2014 TCP transport",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"nativeTaskId": 307,
|
||||
"subject": "Task 11: Verification (build + test + live)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8,
|
||||
9,
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-12"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "OBSOLETE \u2014 targets the bespoke Wonderware TCP/ArchestrA historian sidecar, retired when HistorianGateway became the sole historian backend. There is no Wonderware backend in the tree. Closed as obsolete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,18 +1,111 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase-0-1.md",
|
||||
"tasks": [
|
||||
{"id": 402, "subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1", "status": "pending"},
|
||||
{"id": 403, "subject": "SP Task 1: Phase 0 — correct 7 stale code comments", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 404, "subject": "SP Task 2: Phase 0 — fix docs/security.md + benign-residue comments", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 405, "subject": "SP Task 3: Phase 0 — mark shipped .tasks.json completed", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 406, "subject": "SP Task 4: Phase 1 H1a — Phase7Applier rebuilds on Changed*", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 407, "subject": "SP Task 5: Phase 1 H1b — VirtualTagHostActor respawns changed child", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 408, "subject": "SP Task 6: Phase 1 H5a — EquipmentVirtualTagPlan.Historize + composer", "status": "pending", "blockedBy": [402]},
|
||||
{"id": 409, "subject": "SP Task 7: Phase 1 H5b — DeploymentArtifact decodes Historize (byte-parity)", "status": "pending", "blockedBy": [408]},
|
||||
{"id": 410, "subject": "SP Task 8: Phase 1 H5c — VirtualTagHostActor invokes IHistoryWriter", "status": "pending", "blockedBy": [407, 409]},
|
||||
{"id": 411, "subject": "SP Task 9: Phase 1 H5d — thread IHistoryWriter through DriverHostActor + DI", "status": "pending", "blockedBy": [410, 403]},
|
||||
{"id": 412, "subject": "SP Task 10: Phase 1 — docs + follow-up bookkeeping", "status": "pending", "blockedBy": [411]},
|
||||
{"id": 413, "subject": "SP Task 11: Phase 0+1 — full build + test + integration review", "status": "pending", "blockedBy": [403, 404, 405, 406, 407, 408, 409, 410, 411, 412]}
|
||||
{
|
||||
"id": 402,
|
||||
"subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 403,
|
||||
"subject": "SP Task 1: Phase 0 \u2014 correct 7 stale code comments",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 404,
|
||||
"subject": "SP Task 2: Phase 0 \u2014 fix docs/security.md + benign-residue comments",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 405,
|
||||
"subject": "SP Task 3: Phase 0 \u2014 mark shipped .tasks.json completed",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 406,
|
||||
"subject": "SP Task 4: Phase 1 H1a \u2014 Phase7Applier rebuilds on Changed*",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 407,
|
||||
"subject": "SP Task 5: Phase 1 H1b \u2014 VirtualTagHostActor respawns changed child",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 408,
|
||||
"subject": "SP Task 6: Phase 1 H5a \u2014 EquipmentVirtualTagPlan.Historize + composer",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
402
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 409,
|
||||
"subject": "SP Task 7: Phase 1 H5b \u2014 DeploymentArtifact decodes Historize (byte-parity)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
408
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 410,
|
||||
"subject": "SP Task 8: Phase 1 H5c \u2014 VirtualTagHostActor invokes IHistoryWriter",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
407,
|
||||
409
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 411,
|
||||
"subject": "SP Task 9: Phase 1 H5d \u2014 thread IHistoryWriter through DriverHostActor + DI",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
410,
|
||||
403
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 412,
|
||||
"subject": "SP Task 10: Phase 1 \u2014 docs + follow-up bookkeeping",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
411
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 413,
|
||||
"subject": "SP Task 11: Phase 0+1 \u2014 full build + test + integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
403,
|
||||
404,
|
||||
405,
|
||||
406,
|
||||
407,
|
||||
408,
|
||||
409,
|
||||
410,
|
||||
411,
|
||||
412
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,15 +2,72 @@
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md",
|
||||
"branch": "feat/stillpending-phase-2-servicelevel",
|
||||
"tasks": [
|
||||
{"id": 414, "subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster", "status": "pending"},
|
||||
{"id": 415, "subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)", "status": "pending", "blockedBy": [414]},
|
||||
{"id": 416, "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", "status": "pending", "blockedBy": [415]},
|
||||
{"id": 417, "subject": "P2 Task 3: HealthTick — periodic DB Ask/PipeTo + PreStart immediate refresh", "status": "pending", "blockedBy": [416]},
|
||||
{"id": 418, "subject": "P2 Task 4: PeerProbeSupervisor — one peer probe per driver peer", "status": "pending"},
|
||||
{"id": 419, "subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)", "status": "pending", "blockedBy": [417, 418]},
|
||||
{"id": 420, "subject": "P2 Task 6: docs/Redundancy.md — calculator is WIRED", "status": "pending"},
|
||||
{"id": 421, "subject": "P2 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [419, 420]},
|
||||
{"id": 422, "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", "status": "pending", "blockedBy": [421]}
|
||||
{
|
||||
"id": 414,
|
||||
"subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 415,
|
||||
"subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
414
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 416,
|
||||
"subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
415
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 417,
|
||||
"subject": "P2 Task 3: HealthTick \u2014 periodic DB Ask/PipeTo + PreStart immediate refresh",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
416
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 418,
|
||||
"subject": "P2 Task 4: PeerProbeSupervisor \u2014 one peer probe per driver peer",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 419,
|
||||
"subject": "P2 Task 5: Wire WithOtOpcUaRuntimeActors (dbHealth ref + spawn supervisor)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
417,
|
||||
418
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 420,
|
||||
"subject": "P2 Task 6: docs/Redundancy.md \u2014 calculator is WIRED",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 421,
|
||||
"subject": "P2 Task 7: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
419,
|
||||
420
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 422,
|
||||
"subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
421
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,16 +2,80 @@
|
||||
"planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md",
|
||||
"branch": "feat/stillpending-phase-3-opcua-standards",
|
||||
"tasks": [
|
||||
{"id": 423, "subject": "P3 Task 1: H2-bit — NodePermissions.HistoryUpdate + evaluator mapping", "status": "pending"},
|
||||
{"id": 424, "subject": "P3 Task 2: H6a — mark native conditions (isNative through the sink)", "status": "pending"},
|
||||
{"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]},
|
||||
{"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"},
|
||||
{"id": 427, "subject": "P3 Task 5: H6c — NativeAlarmAckRouter seam + native OnAcknowledge routing", "status": "pending", "blockedBy": [424]},
|
||||
{"id": 428, "subject": "P3 Task 6: H6d — DriverHostActor inverse map + native-ack route to driver", "status": "pending", "blockedBy": [426, 427]},
|
||||
{"id": 429, "subject": "P3 Task 7: H6e — wire NativeAlarmAckRouter in host DI", "status": "pending", "blockedBy": [427, 428]},
|
||||
{"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"},
|
||||
{"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]},
|
||||
{"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]}
|
||||
{
|
||||
"id": 423,
|
||||
"subject": "P3 Task 1: H2-bit \u2014 NodePermissions.HistoryUpdate + evaluator mapping",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 424,
|
||||
"subject": "P3 Task 2: H6a \u2014 mark native conditions (isNative through the sink)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 425,
|
||||
"subject": "P3 Task 3: H4 \u2014 wire OnEnableDisable over OPC UA",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
424
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 426,
|
||||
"subject": "P3 Task 4: H6b \u2014 AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 427,
|
||||
"subject": "P3 Task 5: H6c \u2014 NativeAlarmAckRouter seam + native OnAcknowledge routing",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
424
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 428,
|
||||
"subject": "P3 Task 6: H6d \u2014 DriverHostActor inverse map + native-ack route to driver",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
426,
|
||||
427
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 429,
|
||||
"subject": "P3 Task 7: H6e \u2014 wire NativeAlarmAckRouter in host DI",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
427,
|
||||
428
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 430,
|
||||
"subject": "P3 Task 8: Docs \u2014 Enable/Disable + native-ack\u2192AVEVA + HistoryUpdate bit",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 431,
|
||||
"subject": "P3 Task 9: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
423,
|
||||
425,
|
||||
429,
|
||||
430
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 432,
|
||||
"subject": "P3 Task 10: Live /run \u2014 H4 Enable/Disable + H6 native-ack route",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
431
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-15"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,14 +2,68 @@
|
||||
"planPath": "docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md",
|
||||
"branch": "feat/stillpending-phase-4-driver-datatypes",
|
||||
"tasks": [
|
||||
{"id": 433, "subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)", "status": "pending"},
|
||||
{"id": 434, "subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)", "status": "pending"},
|
||||
{"id": 435, "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", "status": "pending", "blockedBy": [434]},
|
||||
{"id": 436, "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", "status": "pending"},
|
||||
{"id": 437, "subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)", "status": "pending"},
|
||||
{"id": 438, "subject": "P4 Task 6: Docs + bookkeeping", "status": "pending", "blockedBy": [433, 434, 435, 436, 437]},
|
||||
{"id": 439, "subject": "P4 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [433, 434, 435, 436, 437, 438]},
|
||||
{"id": 440, "subject": "P4 Task 8: Live /run — Modbus Int64 (acceptance gate)", "status": "pending", "blockedBy": [439]}
|
||||
{
|
||||
"id": 433,
|
||||
"subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 434,
|
||||
"subject": "P4 Task 2: FOCAS fail-fast factory (EnsureUsable at init)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 435,
|
||||
"subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
434
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 436,
|
||||
"subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 437,
|
||||
"subject": "P4 Task 5: Historian poison-event dead-letter cap (maxAttempts)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 438,
|
||||
"subject": "P4 Task 6: Docs + bookkeeping",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
433,
|
||||
434,
|
||||
435,
|
||||
436,
|
||||
437
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 439,
|
||||
"subject": "P4 Task 7: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
433,
|
||||
434,
|
||||
435,
|
||||
436,
|
||||
437,
|
||||
438
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 440,
|
||||
"subject": "P4 Task 8: Live /run \u2014 Modbus Int64 (acceptance gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
439
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -4,16 +4,61 @@
|
||||
"designCommit": "f90017bc",
|
||||
"baseMaster": "c081917a",
|
||||
"branch": "feat/stillpending-phase-4b-driver-gaps (merged to master 08a65513, deleted)",
|
||||
"executionState": "COMPLETE — shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 — all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).",
|
||||
"nativeTaskIds": {"1": 482, "2": 483, "3a": 484, "3b": 485, "4": 486, "5": 487, "6": 488},
|
||||
"executionState": "COMPLETE \u2014 shipped + pushed to master 08a65513 (ff). Final integration review = SHIP. Build clean; AdminUI 450, Galaxy 279(+3 skip), FOCAS 200, Modbus 277, DriverProbeRegistration 2 \u2014 all green. Live: Modbus typed editor + Build-address proven on the rig's seeded MAIN-modbus-eq (Modbus reconcile, conclusive); Galaxy browse rendered a nested hierarchical root via the real driver path (corroborated, unit-proven 8 ways); FOCAS unit-proven (no CNC).",
|
||||
"nativeTaskIds": {
|
||||
"1": 482,
|
||||
"2": 483,
|
||||
"3a": 484,
|
||||
"3b": 485,
|
||||
"4": 486,
|
||||
"5": 487,
|
||||
"6": 488
|
||||
},
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")", "status": "completed", "commit": "8b4675b1", "reviewFixCommit": "a40c77de"},
|
||||
{"id": 2, "subject": "Task 2: Galaxy nested gobject hierarchy", "status": "completed", "commit": "21c7645d", "reviewFixCommit": "bec37848"},
|
||||
{"id": "3a", "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", "status": "completed", "commit": "3fcbc70c"},
|
||||
{"id": "3b", "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", "status": "completed", "commit": "6855be28"},
|
||||
{"id": 4, "subject": "Task 4: Docs + bookkeeping", "status": "completed", "commit": "08a65513"},
|
||||
{"id": 5, "subject": "Task 5: Full build + test + final integration review", "status": "completed", "note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"},
|
||||
{"id": 6, "subject": "Task 6: Live /run verification", "status": "completed", "note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"}
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")",
|
||||
"status": "completed",
|
||||
"commit": "8b4675b1",
|
||||
"reviewFixCommit": "a40c77de"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: Galaxy nested gobject hierarchy",
|
||||
"status": "completed",
|
||||
"commit": "21c7645d",
|
||||
"reviewFixCommit": "bec37848"
|
||||
},
|
||||
{
|
||||
"id": "3a",
|
||||
"subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)",
|
||||
"status": "completed",
|
||||
"commit": "3fcbc70c"
|
||||
},
|
||||
{
|
||||
"id": "3b",
|
||||
"subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)",
|
||||
"status": "completed",
|
||||
"commit": "6855be28"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Docs + bookkeeping",
|
||||
"status": "completed",
|
||||
"commit": "08a65513"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: Full build + test + final integration review",
|
||||
"status": "completed",
|
||||
"note": "final integration review = SHIP (no Critical/Important); 3 Minor non-blocking notes recorded"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Live /run verification",
|
||||
"status": "completed",
|
||||
"note": "Modbus typed editor live-proven on rig; Galaxy nested root corroborated; FOCAS unit-proven"
|
||||
}
|
||||
],
|
||||
"reviewFollowUps": [
|
||||
"FOCAS minor: no upper-bound clamp on a misbehaving cnc_getfigure value (Math.Pow overflow; same latent risk the manual path had)",
|
||||
@@ -21,5 +66,6 @@
|
||||
"FOCAS: live auto-fetch on the real backend needs a cnc_getfigure command added to the managed FocasWireClient wire protocol (WireFocasClient returns empty today)",
|
||||
"Galaxy minor: 3-node chain cycle provably-correct but not directly tested (2-node mutual cycle is tested)"
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -4,28 +4,121 @@
|
||||
"designCommit": "efccd8d1",
|
||||
"baseMaster": "050164b2",
|
||||
"branch": "feat/stillpending-phase-4c-array-support (merged to master 0f92e9e2, deleted)",
|
||||
"executionState": "COMPLETE — shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 — all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) — all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).",
|
||||
"executionState": "COMPLETE \u2014 shipped + pushed to master 0f92e9e2 (ff, 23 commits). Big-bang all 5 drivers + full AbLegacy. Build clean; OpcUaServer 230, Runtime 272, AdminUI 472, Modbus 289, AbCip 292, TwinCAT 153, S7 143, AbLegacy 188 \u2014 all green. Bundle integration review caught the cross-driver isArray-gating divergence (C-1/C-2 Modbus+AbLegacy ignored isArray; I-1/I-2/I-3 the arrayLength=1 edge); final integration review = SHIP WITH MINOR FOLLOW-UPS (I-1 S7 clarity, I-2 AbCip degenerate-scalar, I-3 AbLegacy >256 author-validation, M-1 array nodes read-only) \u2014 all closed. LIVE /run PASS: a Modbus array equipment tag (HR[0..3], UInt16[4]) materialises as ValueRank=OneDimension/ArrayDimensions=[4] and a Good array value flows end-to-end from the live sim (read over the wire, Status 0x00000000, Value System.UInt16[]); the address-100 first attempt correctly surfaced the sim's Illegal-Data-Address as Bad (faithful device status). S7/AbCip/TwinCAT/AbLegacy unit-proven (fixtures down).",
|
||||
"scope": "Big-bang all 5 drivers (AskUserQuestion) + full AbLegacy array read (AskUserQuestion). 1-D read-surface only; array writes / multi-dim / array historization out of scope (array nodes forced read-only at the applier per review M-1).",
|
||||
"nativeTaskIds": {"1": 495, "2": 496, "3": 497, "4": 498, "5": 499, "6": 500, "7": 501, "8": 502, "9": 503, "10": 504, "11": 505, "12": 506},
|
||||
"nativeTaskIds": {
|
||||
"1": 495,
|
||||
"2": 496,
|
||||
"3": 497,
|
||||
"4": 498,
|
||||
"5": 499,
|
||||
"6": 500,
|
||||
"7": 501,
|
||||
"8": 502,
|
||||
"9": 503,
|
||||
"10": 504,
|
||||
"11": 505,
|
||||
"12": 506
|
||||
},
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Sink contract — EnsureVariable array params", "classification": "high-risk", "status": "completed", "commit": "a7928202", "reviewFixCommit": "3172b7bd"},
|
||||
{"id": 2, "subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier", "classification": "high-risk", "status": "completed", "commit": "71cc4171", "reviewFixCommit": "584e9f2a"},
|
||||
{"id": 3, "subject": "DeploymentArtifact decode byte-parity", "classification": "high-risk", "status": "completed", "commit": "0a747c34", "reviewFixCommit": "eb8a8dc1"},
|
||||
{"id": 4, "subject": "AdminUI driver-agnostic isArray/arrayLength control", "classification": "standard", "status": "completed", "commit": "c2006dfb"},
|
||||
{"id": 5, "subject": "Modbus String/BitInRegister array decode + resolver", "classification": "small", "status": "completed", "commit": "8d3dc321", "reviewFixCommit": "49ac1392"},
|
||||
{"id": 6, "subject": "AbCip libplctag array read + IsArray", "classification": "standard", "status": "completed", "commit": "f4d5a5ee", "reviewFixCommit": "94e8c55b+5f7a2acd"},
|
||||
{"id": 7, "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", "classification": "standard", "status": "completed", "commit": "3e742395"},
|
||||
{"id": 8, "subject": "S7 block-read array + decode loop + IsArray", "classification": "high-risk", "status": "completed", "commit": "a82c22c6", "reviewFixCommit": "3bbe39c1+d30fb77e"},
|
||||
{"id": 9, "subject": "AbLegacy PCCC multi-element array read + IsArray", "classification": "high-risk", "status": "completed", "commit": "95006939", "reviewFixCommit": "ce5d46be+0f92e9e2"},
|
||||
{"id": 10, "subject": "Docs + bookkeeping", "classification": "small", "status": "completed", "commit": "05c7e86f"},
|
||||
{"id": 11, "subject": "Full build + test + final integration review", "classification": "standard", "status": "completed", "note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"},
|
||||
{"id": 12, "subject": "Live /run acceptance + finish branch", "classification": "standard", "status": "completed", "note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"}
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Sink contract \u2014 EnsureVariable array params",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "a7928202",
|
||||
"reviewFixCommit": "3172b7bd"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "71cc4171",
|
||||
"reviewFixCommit": "584e9f2a"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "DeploymentArtifact decode byte-parity",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "0a747c34",
|
||||
"reviewFixCommit": "eb8a8dc1"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "AdminUI driver-agnostic isArray/arrayLength control",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"commit": "c2006dfb"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Modbus String/BitInRegister array decode + resolver",
|
||||
"classification": "small",
|
||||
"status": "completed",
|
||||
"commit": "8d3dc321",
|
||||
"reviewFixCommit": "49ac1392"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "AbCip libplctag array read + IsArray",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"commit": "f4d5a5ee",
|
||||
"reviewFixCommit": "94e8c55b+5f7a2acd"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"commit": "3e742395"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "S7 block-read array + decode loop + IsArray",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "a82c22c6",
|
||||
"reviewFixCommit": "3bbe39c1+d30fb77e"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "AbLegacy PCCC multi-element array read + IsArray",
|
||||
"classification": "high-risk",
|
||||
"status": "completed",
|
||||
"commit": "95006939",
|
||||
"reviewFixCommit": "ce5d46be+0f92e9e2"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Docs + bookkeeping",
|
||||
"classification": "small",
|
||||
"status": "completed",
|
||||
"commit": "05c7e86f"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Full build + test + final integration review",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"note": "final integration review = SHIP WITH MINOR FOLLOW-UPS; 4 review fixes applied (d30fb77e/5f7a2acd/0f92e9e2/3bb2031d)"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Live /run acceptance + finish branch",
|
||||
"classification": "standard",
|
||||
"status": "completed",
|
||||
"note": "Modbus array tag live-proven (Good UInt16[4] from sim HR[0..3], over the wire); others unit-proven; root-caused the address-100 Bad as a sim Illegal-Data-Address (not a code bug)"
|
||||
}
|
||||
],
|
||||
"reviewFollowUps": [
|
||||
"Foundation array test asserts in-process variable.Value only — add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)",
|
||||
"Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements — add array element formatting",
|
||||
"Foundation array test asserts in-process variable.Value only \u2014 add a wire-level Session.ReadValueAsync assertion over an array node (the live /run proved it works, but it's not unit-covered)",
|
||||
"Client.CLI read/subscribe prints an array value's type name (System.UInt16[]) not its elements \u2014 add array element formatting",
|
||||
"Array WRITES (inbound client->device), multi-dimensional arrays, array historization remain out of scope (named deferrals)",
|
||||
"Live array read for S7/AbCip/TwinCAT/AbLegacy is fixture-gated/operator-gated (unit-proven only); the 5 AbLegacy libplctag PCCC array-read assumptions need a real SLC/MicroLogix to confirm"
|
||||
],
|
||||
"lastUpdated": "2026-06-16"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped \u2014 the stillpending backlog audit cites the landing commits (1e95856b, ada01e1a, fc8121cb, 3e609a2b, 70e6d3d2, c236263e, bd8fee61, 5e27b5f7, fcb38014). Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,27 +1,185 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-26-otopcua-historian-gateway-integration.md",
|
||||
"tasks": [
|
||||
{ "id": 0, "subject": "Task 1: Consume gateway packages + scaffold Gateway driver project", "status": "pending", "blockedBy": [] },
|
||||
{ "id": 1, "subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 2, "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 3, "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 4, "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 5, "subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 6, "subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)", "status": "pending", "blockedBy": [1, 3] },
|
||||
{ "id": 7, "subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus", "status": "pending", "blockedBy": [6] },
|
||||
{ "id": 8, "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", "status": "pending", "blockedBy": [0] },
|
||||
{ "id": 9, "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", "status": "pending", "blockedBy": [6, 8] },
|
||||
{ "id": 10, "subject": "Task 11: ReadEventsAsync alarm-history on the data source", "status": "pending", "blockedBy": [6, 4] },
|
||||
{ "id": 11, "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", "status": "pending", "blockedBy": [9, 5] },
|
||||
{ "id": 12, "subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs", "status": "pending", "blockedBy": [11] },
|
||||
{ "id": 13, "subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)", "status": "pending", "blockedBy": [9, 2] },
|
||||
{ "id": 14, "subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()", "status": "pending", "blockedBy": [13] },
|
||||
{ "id": 15, "subject": "Task 16: FasterLog historization outbox store", "status": "pending", "blockedBy": [9] },
|
||||
{ "id": 16, "subject": "Task 17: ContinuousHistorizationRecorder actor", "status": "pending", "blockedBy": [15, 9] },
|
||||
{ "id": 17, "subject": "Task 18: Wire recorder into DI + hosted lifecycle", "status": "pending", "blockedBy": [16] },
|
||||
{ "id": 18, "subject": "Task 19: Retire Wonderware historian projects", "status": "pending", "blockedBy": [9, 12, 17, 19] },
|
||||
{ "id": 19, "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", "status": "pending", "blockedBy": [9, 10, 12, 17] },
|
||||
{ "id": 20, "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", "status": "pending", "blockedBy": [18] }
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 1: Consume gateway packages + scaffold Gateway driver project",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 6: AlarmHistorianEvent->HistorianEvent mapper (SendEvent)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 7: GatewayHistorianDataSource read paths (raw/processed/at-time)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 8: GetHealthSnapshot via Probe/GetConnectionStatus",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 9: Reshape ServerHistorianOptions to gateway form",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 11: ReadEventsAsync alarm-history on the data source",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 13: Swap AddAlarmHistorian factory in Program.cs",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 14: IHistorianProvisioning + GatewayTagProvisioner (EnsureTags)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 15: Hook provisioning into AddressSpaceApplier.Apply()",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 16: FasterLog historization outbox store",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 17: ContinuousHistorizationRecorder actor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
15,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 18: Wire recorder into DI + hosted lifecycle",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 19: Retire Wonderware historian projects",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
12,
|
||||
17,
|
||||
19
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 20: Env-gated live validation vs wonder-sql-vd03",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
10,
|
||||
12,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
18
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-26"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Shipped as PR #423 and live-validated against the gateway on wonder-sql-vd03. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -2,14 +2,69 @@
|
||||
"planPath": "docs/plans/2026-07-22-per-cluster-mesh-program.md",
|
||||
"note": "PROGRAM plan: each task = write that phase's detailed plan (writing-plans), execute it (executing-plans), run its exit gate, update the tracking tables. Prereq task 0 is the separate selfform-fallback plan in this repo.",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)", "status": "completed", "note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."},
|
||||
{"id": 1, "subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB", "status": "completed", "note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."},
|
||||
{"id": 2, "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", "status": "in_progress", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "subject": "Phase 4: cut driver-side ConfigDb — EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 6, "subject": "Phase 6: mesh partition — per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope", "status": "pending", "blockedBy": [0, 4, 5]},
|
||||
{"id": 7, "subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook", "status": "pending", "blockedBy": [6]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)",
|
||||
"status": "completed",
|
||||
"note": "Shipped as self-first seed ordering + AkkaClusterOptionsValidator, NOT the planned SelfFormAfter watchdog (live gate caught it islanding a failed-over node). Merged a78425ea."
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Phase 1: ClusterNode Akka+gRPC address columns; ConfigPublishCoordinator ack set from DB",
|
||||
"status": "completed",
|
||||
"note": "Merged 7654f24d. Escape hatch is ClusterNode.MaintenanceMode, not Enabled=0."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Phase 4: cut driver-side ConfigDb \u2014 EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Phase 5: gRPC oneof stream contract; migrate 7 observability topics; AdminUI reconnect story",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Phase 6: mesh partition \u2014 per-pair seeds, cluster-scoped roles/singletons, co-location rig rewrite, secrets replication re-scope",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
4,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Phase 7: failover drill per pair (incl. shared-VM failure) + close auto-down 1v1 and self-first cold-start-alone live gates + operator runbook",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-22T00:00:00Z"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Program COMPLETE (Phases 0a-7 + bootstrap guard), origin/master d1dac87f. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore",
|
||||
"status": "deferred",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
],
|
||||
@@ -128,5 +128,6 @@
|
||||
"result": "LIVE GATE PASSED \u2014 deploy sealed green w/ 4 DB-less site nodes acking; ServiceLevel 240 w/ central SQL down; restart booted last-known-good from LocalDb pointer; grep proof; alarm_condition_state table live+replicated. Doc: 2026-07-23-mesh-phase4-live-gate.md"
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-23"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Task 9 landed 3a590a0c; live gate PASSED 1281aebf. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> **Program design (architecture / shared contract / build order):**
|
||||
> [`2026-07-15-driver-expansion-program-design.md`](2026-07-15-driver-expansion-program-design.md)
|
||||
>
|
||||
> Last updated: 2026-07-24.
|
||||
> Last updated: 2026-07-27.
|
||||
|
||||
## Legend
|
||||
|
||||
@@ -30,8 +30,8 @@ it reaches 📝.
|
||||
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | S–M | none (retrofits shipped drivers) |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 🟢 **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 |
|
||||
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | ✅ **Done** — merged to master `0f38f486` (#495), 11 tasks, live gate PASSED | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
|
||||
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | ✅ **Done** — merged to master `4ad54037`, 22 tasks, live gate PASSED; follow-ups #496/#497/#498 closed in `28c28667` | S–M | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
|
||||
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | ✅ **COMPLETE** — P1 Agent MVP, all 23 tasks + the 5-leg live gate PASSED (branch `feat/mtconnect-driver`, PR #506) | S–M | Dockerized `mtconnect/agent:2.7.0.12` (not `cppagent`) — CI-simulatable, live at `10.100.0.35:5000` |
|
||||
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | ✅ **COMPLETE** — P1 + P2 shipped, both live-gated (Tasks 0–26) | M–L | Mosquitto TLS+auth **+ C# Sparkplug edge-node simulator**, live at `10.100.0.35:8883` |
|
||||
|
||||
@@ -49,22 +49,28 @@ running the classification-driven review chain (`trivial` = implement only … `
|
||||
spec-review → code-review → integration review) between tasks. The `.tasks.json` next to each plan
|
||||
tracks progress and lets a later session resume.
|
||||
|
||||
**Paste one of these into Claude Code to build a driver:**
|
||||
> ⚠️ **All four plans below have been executed and merged.** This table is retained as the *pattern*
|
||||
> for executing a future driver plan — do **not** run these four commands, they would rebuild shipped
|
||||
> drivers in a fresh worktree. (Their `.tasks.json` files still read `pending`; that is stale
|
||||
> bookkeeping, not backlog — see `deferment.md` §6.1.)
|
||||
|
||||
| Deliverable | Command |
|
||||
|---|---|
|
||||
| Modbus RTU (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-modbus-rtu-driver.md in a new git worktree` |
|
||||
| SQL poll (Wave 1) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-sql-poll-driver.md in a new git worktree` |
|
||||
| MTConnect (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mtconnect-driver.md in a new git worktree` |
|
||||
| MQTT/Sparkplug (Wave 2) | `Use superpowers-extended-cc:subagent-driven-development to execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md in a new git worktree` |
|
||||
| Deliverable | Status | Command (pattern only — do not re-run) |
|
||||
|---|---|---|
|
||||
| Modbus RTU (Wave 1) | ✅ merged `0f38f486` | ~~`… execute docs/plans/2026-07-24-modbus-rtu-driver.md …`~~ |
|
||||
| SQL poll (Wave 1) | ✅ merged `4ad54037` | ~~`… execute docs/plans/2026-07-24-sql-poll-driver.md …`~~ |
|
||||
| MTConnect (Wave 2) | ✅ merged `90bdaa44` (#506) | ~~`… execute docs/plans/2026-07-24-mtconnect-driver.md …`~~ |
|
||||
| MQTT/Sparkplug (Wave 2) | ✅ merged `c3a2b0f7` | ~~`… execute docs/plans/2026-07-24-mqtt-sparkplug-driver.md …`~~ |
|
||||
|
||||
For a **new** driver the invocation shape is:
|
||||
`Use superpowers-extended-cc:subagent-driven-development to execute <plan-path> in a new git worktree`
|
||||
|
||||
- **Slash-command form** (equivalent): `/superpowers-extended-cc:subagent-driven-development <plan-path>`.
|
||||
- **Parallel-session / resume form** (batch execution with checkpoints, no fresh-subagent-per-task):
|
||||
`/superpowers-extended-cc:executing-plans <plan-path>` — reads the same `.tasks.json` and continues
|
||||
from the first pending task.
|
||||
- **Recommended order:** Modbus RTU → SQL poll → MTConnect → MQTT/Sparkplug (lowest effort first;
|
||||
see each wave below for the gating notes). Run one plan per worktree; the four plans are
|
||||
independent, so separate worktrees may run concurrently.
|
||||
- **Order (historical):** the four were built lowest-effort-first — Modbus RTU → SQL poll → MTConnect
|
||||
→ MQTT/Sparkplug. All are merged; nothing in this list remains to schedule. Run one plan per
|
||||
worktree; independent plans may run concurrently in separate worktrees.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,12 +93,12 @@ marginal cost.
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 — low-effort / high-leverage pair 📝
|
||||
## Wave 1 — low-effort / high-leverage pair ✅
|
||||
|
||||
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
|
||||
SQL poll reuses the always-on central SQL Server. This is the recommended next build.
|
||||
SQL poll reuses the always-on central SQL Server. **Both shipped.**
|
||||
|
||||
### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
|
||||
### Modbus RTU — ✅ Done (merged `0f38f486` / #495, live gate PASSED)
|
||||
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk), all complete via subagent-driven-development.
|
||||
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
|
||||
@@ -117,11 +123,14 @@ SQL poll reuses the always-on central SQL Server. This is the recommended next b
|
||||
→ **5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
|
||||
- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
|
||||
|
||||
### SQL poll — 📝 Plan ready
|
||||
### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED)
|
||||
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks**. `ISqlDialect` seam in from day one; only the SQL Server dialect built in v1 (Postgres/ODBC deferred).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server P1;
|
||||
Postgres/ODBC land in P2/P3 behind `ISqlDialect`).
|
||||
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam.
|
||||
- **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate
|
||||
(`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a
|
||||
persisted `connectionString` (`1919a8e5`).
|
||||
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server
|
||||
shipped; Postgres/ODBC deferred behind `ISqlDialect`).
|
||||
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
|
||||
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
|
||||
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
|
||||
@@ -130,7 +139,7 @@ SQL poll reuses the always-on central SQL Server. This is the recommended next b
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — strategic telemetry / UNS pair 📝
|
||||
## Wave 2 — strategic telemetry / UNS pair ✅
|
||||
|
||||
Both CI-simulatable on the shared docker host, no hardware.
|
||||
|
||||
@@ -242,9 +251,10 @@ Both CI-simulatable on the shared docker host, no hardware.
|
||||
|
||||
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).
|
||||
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
|
||||
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
|
||||
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
|
||||
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
|
||||
3. **Wave 2 is done.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). Remaining work on both is optional follow-on — MQTT P3 write-through (`IWritable`: NCMD/DCMD + plain publish) plus its two browse-UI
|
||||
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
|
||||
|
||||
|
||||
@@ -1,17 +1,91 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)", "status": "pending", "blockedBy": [2, 3]},
|
||||
{"id": 5, "subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode", "status": "pending", "blockedBy": [0, 4]},
|
||||
{"id": 6, "subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 9, "subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test", "status": "pending", "blockedBy": [4, 7]},
|
||||
{"id": 10, "subject": "Task 10: Live /run verification on docker-dev", "status": "pending", "blockedBy": [8, 9]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: ModbusTransportFactory.Create switch on Transport mode",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Bind Transport on options/DTO/factory (string-enum guard)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: AdminUI Transport selector on ModbusDriverForm",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: Docker rtu_over_tcp fixture + RTU-over-TCP integration test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: Live /run verification on docker-dev",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8,
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Merged 0f38f486 (#495); live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,34 +1,315 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mqtt-sparkplug-driver.md",
|
||||
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet — its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
|
||||
"note": "Wave 2 MQTT/Sparkplug B driver. Two phases both in-scope: P1 (plain MQTT, Tasks 0-14) is a complete shippable milestone; P2 (Sparkplug B ingest, Tasks 15-26) builds on it and every P2 task chains back to Task 14. Write-through (NCMD/DCMD/plain publish) is DEFERRED (design P3). Hard rules: Task 0 (MQTTnet-5/net10 restore+build under central pinning with transitive pinning OFF) is the mandatory FIRST gate. Hand-roll Tahu proto over ONE MQTTnet-5 client (NOT SparkplugNet \u2014 its MQTTnet-4.x transitive pin collides with the repo's deliberately-OFF CentralPackageTransitivePinning). DriverType string is 'Mqtt' everywhere (grep-enforced). Enum-serialization trap: every JSON seam uses shared JsonSerializerOptions with JsonStringEnumConverter. Fixtures enforce TLS+auth, never anonymous/public-broker. Google.Protobuf (3.34.1) + Grpc.Tools (2.76.0) already pinned; MQTTnet is the one new pin. High-risk tasks: 4 (hand-rolled reconnect loop), 18/19 (alias/seq state-machine components), 21 (the full 3.6 ingest matrix).",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0 (P1): Dependency-validation spike — MQTTnet-5 pin + net10 restore/build under central pinning", "status": "pending", "classification": "standard", "parallelizableWith": []},
|
||||
{"id": 1, "subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]},
|
||||
{"id": 6, "subject": "Task 6 (P1): ISubscribable plain topic subscribe→OnDataChange + retained seed", "status": "pending", "classification": "standard", "parallelizableWith": [5], "blockedBy": [2, 4]},
|
||||
{"id": 7, "subject": "Task 7 (P1): MqttDriver shell — IDriver + authored-only ITagDiscovery (Once) + probe interface", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake", "status": "pending", "classification": "small", "parallelizableWith": [10], "blockedBy": [3]},
|
||||
{"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]},
|
||||
{"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]},
|
||||
{"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]},
|
||||
{"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14 (P1): Live /run verify on docker-dev — P1 MILESTONE COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [9, 11, 12, 13]},
|
||||
{"id": 15, "subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors", "status": "pending", "classification": "standard", "parallelizableWith": [17], "blockedBy": [15]},
|
||||
{"id": 17, "subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map", "status": "pending", "classification": "small", "parallelizableWith": [16], "blockedBy": [15]},
|
||||
{"id": 18, "subject": "Task 18 (P2): BirthCache + AliasTable — bind-by-name, rebuild-per-birth", "status": "pending", "classification": "high-risk", "parallelizableWith": [19], "blockedBy": [16, 17]},
|
||||
{"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]},
|
||||
{"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]},
|
||||
{"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]},
|
||||
{"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]},
|
||||
{"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]},
|
||||
{"id": 25, "subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21, 22, 23]},
|
||||
{"id": 26, "subject": "Task 26 (P2): Live /run verify Sparkplug — P2 COMPLETE", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [23, 24, 25]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0 (P1): Dependency-validation spike \u2014 MQTTnet-5 pin + net10 restore/build under central pinning",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
6
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6 (P1): ISubscribable plain topic subscribe\u2192OnDataChange + retained seed",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
5
|
||||
],
|
||||
"blockedBy": [
|
||||
2,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7 (P1): MqttDriver shell \u2014 IDriver + authored-only ITagDiscovery (Once) + probe interface",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8 (P1): MqttDriverProbe CONNECT handshake",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
10
|
||||
],
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
7,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
8
|
||||
],
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)",
|
||||
"status": "completed",
|
||||
"classification": "trivial",
|
||||
"parallelizableWith": [
|
||||
12
|
||||
],
|
||||
"blockedBy": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
11
|
||||
],
|
||||
"blockedBy": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14 (P1): Live /run verify on docker-dev \u2014 P1 MILESTONE COMPLETE",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
9,
|
||||
11,
|
||||
12,
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15 (P2): Vendor Tahu sparkplug_b.proto + Grpc.Tools codegen",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 16 (P2): SparkplugCodec decode + golden payload vectors",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
17
|
||||
],
|
||||
"blockedBy": [
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 17 (P2): SparkplugTopic parse/format + SparkplugDataType.ToDriverDataType map",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
16
|
||||
],
|
||||
"blockedBy": [
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 18 (P2): BirthCache + AliasTable \u2014 bind-by-name, rebuild-per-birth",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [
|
||||
19
|
||||
],
|
||||
"blockedBy": [
|
||||
16,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [
|
||||
18
|
||||
],
|
||||
"blockedBy": [
|
||||
16,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 20 (P2): RebirthRequester NCMD encode",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"subject": "Task 21 (P2): Sparkplug ingest state machine \u2014 the 3.6 matrix",
|
||||
"status": "completed",
|
||||
"classification": "high-risk",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
18,
|
||||
19,
|
||||
20
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
21
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [
|
||||
24
|
||||
],
|
||||
"blockedBy": [
|
||||
20,
|
||||
21
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [
|
||||
23
|
||||
],
|
||||
"blockedBy": [
|
||||
12,
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 25,
|
||||
"subject": "Task 25 (P2): C# edge-node simulator fixture + 3.6 live matrix",
|
||||
"status": "completed",
|
||||
"classification": "standard",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
21,
|
||||
22,
|
||||
23
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"subject": "Task 26 (P2): Live /run verify Sparkplug \u2014 P2 COMPLETE",
|
||||
"status": "completed",
|
||||
"classification": "small",
|
||||
"parallelizableWith": [],
|
||||
"blockedBy": [
|
||||
23,
|
||||
24,
|
||||
25
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "All 27 tasks shipped, both phases live-gated. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,29 +1,199 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-mtconnect-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: Scaffold the two driver projects + the test project", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Capture the canned XML fixtures", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "subject": "Task 5: IMTConnectAgentClient seam + return DTOs", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 6, "subject": "Task 6: MTConnectAgentClient — parse /probe into the device model", "status": "pending", "blockedBy": [4, 5]},
|
||||
{"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]},
|
||||
{"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]},
|
||||
{"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 11: ISubscribable — /sample long-poll pump + ring-buffer re-baseline", "status": "pending", "blockedBy": [9, 7]},
|
||||
{"id": 12, "subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true", "status": "pending", "blockedBy": [9, 6]},
|
||||
{"id": 13, "subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 14, "subject": "Task 14: MTConnectDriverProbe : IDriverProbe", "status": "pending", "blockedBy": [2, 5]},
|
||||
{"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]},
|
||||
{"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]},
|
||||
{"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]},
|
||||
{"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 19: mtconnect/cppagent docker fixture", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 20, "subject": "Task 20: Env-gated integration suite against cppagent", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verify on docker-dev — browse picker, editor, read, subscribe, deploy", "status": "pending", "blockedBy": [18, 20]},
|
||||
{"id": 22, "subject": "Task 22: Docs + deferred-writeback note; update the tracking doc", "status": "pending", "blockedBy": [21]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: Scaffold the two driver projects + the test project",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: Capture the canned XML fixtures",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: IMTConnectAgentClient seam + return DTOs",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: MTConnectAgentClient \u2014 parse /probe into the device model",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: MTConnectAgentClient \u2014 parse /current + /sample, detect the sequence gap",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: MTConnectDriver shell \u2014 IDriver lifecycle",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: IReadable.ReadAsync \u2014 /current, ordered snapshots",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: ISubscribable \u2014 /sample long-poll pump + ring-buffer re-baseline",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12: ITagDiscovery.DiscoverAsync + SupportsOnlineDiscovery=true",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13: IHostConnectivityProbe + IRediscoverable (instanceId watch)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14: MTConnectDriverProbe : IDriverProbe",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15: MTConnectDriverFactoryExtensions",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
9
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
14,
|
||||
15
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 17: AdminUI typed model MTConnectTagConfigModel",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3,
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 18: AdminUI editor razor + map/validator registration",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
17
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 19: mtconnect/cppagent docker fixture",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 20: Env-gated integration suite against cppagent",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
19
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"subject": "Task 21: Live /run verify on docker-dev \u2014 browse picker, editor, read, subscribe, deploy",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
18,
|
||||
20
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"subject": "Task 22: Docs + deferred-writeback note; update the tracking doc",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
21
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Merged at master 90bdaa44 (#506); 5-leg live gate PASSED. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
@@ -1,28 +1,191 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-07-24-sql-poll-driver.md",
|
||||
"tasks": [
|
||||
{"id": 0, "subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx", "status": "pending"},
|
||||
{"id": 1, "subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]},
|
||||
{"id": 4, "subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)", "status": "pending", "blockedBy": [1, 3]},
|
||||
{"id": 5, "subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)", "status": "pending", "blockedBy": [2, 4]},
|
||||
{"id": 6, "subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture", "status": "pending", "blockedBy": [3, 4]},
|
||||
{"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]},
|
||||
{"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]},
|
||||
{"id": 11, "subject": "Task 11: Host registration — DriverTypeNames.Sql + factory + probe + guard test", "status": "pending", "blockedBy": [9, 10]},
|
||||
{"id": 12, "subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 13, "subject": "Task 13: SqlBrowseSession — schema walk over dialect catalog", "status": "pending", "blockedBy": [4, 12, 6]},
|
||||
{"id": 14, "subject": "Task 14: SqlDriverBrowser — env-ref/literal transient-connection open", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]},
|
||||
{"id": 19, "subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)", "status": "pending", "blockedBy": [1, 11]},
|
||||
{"id": 20, "subject": "Task 20: SqlTagConfigEditor.razor shell", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 21, "subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)", "status": "pending", "blockedBy": [11, 15, 20]}
|
||||
{
|
||||
"id": 0,
|
||||
"subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"subject": "Task 4: ISqlDialect + SqlServerDialect (QuoteIdentifier, catalog SQL, MapColumnType)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"subject": "Task 5: SqlQueryPlan grouping + parameter binding (pure)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
2,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"subject": "Task 6: Scaffold Driver.Sql.Tests + SqliteDialect + SqlitePollFixture",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"subject": "Task 7: SqlPollReader core \u2014 grouped read, bounded deadline, ordered slice-back",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
5,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"subject": "Task 8: SqlDriver shell \u2014 IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"subject": "Task 11: Host registration \u2014 DriverTypeNames.Sql + factory + probe + guard test",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
9,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"subject": "Task 12: Scaffold Driver.Sql.Browser project + register in slnx",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"subject": "Task 13: SqlBrowseSession \u2014 schema walk over dialect catalog",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
4,
|
||||
12,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"subject": "Task 14: SqlDriverBrowser \u2014 env-ref/literal transient-connection open",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"subject": "Task 17: Injection regression test (bind value / reject identifier)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
16
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"subject": "Task 19: AdminUI typed Sql tag-config model + validator (string enums)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
1,
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"subject": "Task 20: SqlTagConfigEditor.razor shell",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
19
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"subject": "Task 21: Live /run verification on docker-dev (picker + editor + deploy + read)",
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
11,
|
||||
15,
|
||||
20
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-07-24"
|
||||
"lastUpdated": "2026-07-27",
|
||||
"closureNote": "Merged 4ad54037 + follow-ups 28c28667. Marked complete 2026-07-27 (deferment.md \u00a78.5)."
|
||||
}
|
||||
|
||||
+115
-28
@@ -21,7 +21,7 @@ OtOpcUa has four independent security concerns. This document covers all four:
|
||||
|
||||
1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust).
|
||||
2. **OPC UA authentication** — Anonymous / UserName / X.509 session identities; UserName tokens authenticated by LDAP bind.
|
||||
3. **Data-plane authorization** — who can browse, read, subscribe, write, acknowledge alarms on which nodes. Evaluated by `TriePermissionEvaluator` over a `PermissionTrie` built from the Config DB `NodeAcl` tree.
|
||||
3. **Data-plane authorization** — who can write and acknowledge alarms on an OtOpcUa endpoint. ⚠️ Enforced today by **two coarse, server-wide LDAP role checks** (`WriteOperate`, `AlarmAck`) — **not** by the `NodeAcl` / `PermissionTrie` subsystem, which is built and unit-tested but never wired. Read, Browse, HistoryRead and Subscribe carry **no** authorization check at all. See [Data-Plane Authorization](#data-plane-authorization).
|
||||
4. **Control-plane authorization** — who can view or edit fleet configuration in the Admin UI. Gated by the `AdminRole` (`Viewer` / `Designer` / `Administrator`) claim resolved from `LdapGroupRoleMapping`.
|
||||
|
||||
Transport security and OPC UA authentication are per-node concerns configured in the Server's bootstrap `appsettings.json`. Data-plane ACLs and Admin role grants live in the Config DB.
|
||||
@@ -112,9 +112,9 @@ The Server accepts three OPC UA identity-token types:
|
||||
|
||||
| Token | Handler | Notes |
|
||||
|---|---|---|
|
||||
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | Data-plane authorization (below) still default-denies any node a session has no ACL grant for. |
|
||||
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and attached to the OPC UA session identity for the downstream ACL evaluator. |
|
||||
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow); finer-grain authorization happens through the data-plane ACLs. |
|
||||
| Anonymous | No `IOpcUaUserAuthenticator` call — the SDK admits anonymous sessions at the channel. | ⚠️ An anonymous session carries **no roles**, so it is refused every write and every alarm ack — but it can **Browse, Read, Subscribe and HistoryRead the entire address space**. There is no per-node ACL check on those operations (see [Data-Plane Authorization](#data-plane-authorization)). Do not expose an Anonymous-admitting endpoint to an untrusted network. |
|
||||
| UserName/Password | `LdapOpcUaUserAuthenticator.AuthenticateUserNameAsync` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs`, implements `IOpcUaUserAuthenticator`), backed by the app `ILdapAuthService` — `OtOpcUaLdapAuthService` (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs`). | LDAP bind + group lookup. The returned LDAP groups are mapped to roles via `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) and the **roles** are attached to the OPC UA session identity as a `RoleCarryingUserIdentity`. The raw group list is discarded at that point and never reaches the data plane. |
|
||||
| X.509 Certificate | Stack-level acceptance during the secure-channel handshake. | The certificate must be trusted (see PKI trust flow). It carries **no** roles, so a cert-only session is read-everything / write-nothing. |
|
||||
|
||||
When no authenticator is supplied, `OpcUaApplicationHost` falls back to `NullOpcUaUserAuthenticator`; the Host wires the real `LdapOpcUaUserAuthenticator` as a singleton in `Program.cs`.
|
||||
|
||||
@@ -130,7 +130,7 @@ LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `s
|
||||
4. Delegates the real path to the shared `ZB.MOM.WW.Auth.Ldap` client: it binds (search-then-bind via `ServiceAccountDn`, or direct-bind `cn={user},{SearchBase}` when no service account is set), verifies the password, and reads the user's group memberships.
|
||||
5. Returns an `LdapAuthResult` carrying the validated username + the **groups** (never roles). Failure codes are folded into opaque user-facing error strings so a probe cannot distinguish "unknown user" from "wrong password".
|
||||
|
||||
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity for the ACL evaluator. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
|
||||
**Group → role mapping happens downstream**, not in the auth service: `LdapOpcUaUserAuthenticator` resolves `IGroupRoleMapper<string>` (`OtOpcUaGroupRoleMapper`) per call and unions its output with any pre-resolved roles (the DevStub `Administrator` grant). The roles are attached to the OPC UA session identity (`RoleCarryingUserIdentity`), where the write and alarm-ack gates read them. **The LDAP group names themselves are consumed here and go no further** — which is one reason the `NodeAcl` subsystem cannot currently be wired, since it matches on groups rather than roles. A mapper fault (e.g. a Config DB outage) falls back to the pre-resolved baseline rather than denying an otherwise-authenticated session.
|
||||
|
||||
`Transport` replaces the former `UseTls` bool: `Ldaps` (implicit TLS), `StartTls` (upgrade), or `None` (plaintext, requires `AllowInsecure`). Configuration example (Active Directory production):
|
||||
|
||||
@@ -180,11 +180,92 @@ Defaults are behaviour-neutral on a healthy directory. All new paths are **fail-
|
||||
|
||||
## Data-Plane Authorization
|
||||
|
||||
Data-plane authorization is the check run on every OPC UA operation against an OtOpcUa endpoint: *can this authenticated user Browse / Read / Subscribe / Write / HistoryRead / AckAlarm / Call on this specific node?*
|
||||
> ⚠️ **Read this first (verified against source, 2026-07-27).** This section used to describe the
|
||||
> `NodeAcl` / `PermissionTrie` design as if it were the live enforcement path. **It is not.** The
|
||||
> evaluator is built and unit-tested but has **zero production call sites**, and
|
||||
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not even reference the project it lives in. Everything
|
||||
> actually enforced today is in [What is enforced today](#what-is-enforced-today); the trie design is
|
||||
> retained below, clearly marked, under [Designed but not wired](#designed-but-not-wired-nodeacl--permissiontrie).
|
||||
|
||||
Per decision #129 the model is **additive-only — no explicit Deny**. Grants at each hierarchy level union; absence of a grant is the default-deny.
|
||||
### What is enforced today
|
||||
|
||||
### Hierarchy
|
||||
There are exactly **three** authorization checks in the whole OPC UA server layer. All three are
|
||||
coarse, **server-wide role string checks** with no per-node component.
|
||||
|
||||
| Operation | Gate | Requires |
|
||||
|---|---|---|
|
||||
| Write (Value attribute, either namespace) | `OtOpcUaNodeManager.EvaluateEquipmentWriteGate` | role `WriteOperate` |
|
||||
| Alarm Acknowledge / Confirm / AddComment / Shelve / Unshelve (scripted alarms) | `OtOpcUaNodeManager.HandleAlarmCommand` | role `AlarmAck` |
|
||||
| Alarm acknowledge (driver-fed native alarms) | `OtOpcUaNodeManager.HandleNativeAlarmAck` | role `AlarmAck` |
|
||||
|
||||
Both gates fail closed — a session with no identity or no matching role gets
|
||||
`BadUserAccessDenied` — and read `identity.Roles` off the session's `RoleCarryingUserIdentity`
|
||||
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`). Role strings are
|
||||
the constants in `OpcUaDataPlaneRoles`, sourced from `Security:Ldap:GroupToRole` (see
|
||||
[Role grant source](#role-grant-source-data-plane)).
|
||||
|
||||
**What has no authorization check at all:**
|
||||
|
||||
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
|
||||
an Anonymous one, sees and reads the entire address space.
|
||||
- **HistoryRead** — all four overrides (`HistoryReadRawModified`, `HistoryReadProcessed`,
|
||||
`HistoryReadAtTime`, `HistoryReadEvents`) run without an identity check.
|
||||
- **CreateMonitoredItems / subscriptions / TransferSubscriptions**.
|
||||
- **Non-Value attribute writes** — the gate hangs off `OnWriteValue`.
|
||||
|
||||
**And what the write gate does *not* distinguish:**
|
||||
|
||||
- **`WriteTune` and `WriteConfigure` are never checked.** They exist in the role vocabulary and in
|
||||
`NodePermissions`, but every write — whatever the tag's security classification — is gated on the
|
||||
single `WriteOperate` string.
|
||||
- **`AlarmAcknowledge` / `AlarmConfirm` / `AlarmShelve` are not distinguished.** One `AlarmAck` role
|
||||
covers all five alarm methods; the `operation` argument is passed to the router but never consulted
|
||||
by the gate.
|
||||
- **There is no per-node granularity.** A session that may write one tag may write **every** writable
|
||||
tag on the node.
|
||||
|
||||
The gate *is* realm-aware in one narrow sense: it is attached per-node during materialization for both
|
||||
the Raw and UNS realms, and the write dispatch it guards is realm-qualified. The role decision itself
|
||||
takes no realm and no node.
|
||||
|
||||
### Designed but not wired: `NodeAcl` + `PermissionTrie`
|
||||
|
||||
Everything from here to [Role grant source](#role-grant-source-data-plane) describes a subsystem that
|
||||
**exists in the tree, is covered by 30 unit tests, and never executes.** It is kept because the design
|
||||
is sound and the remaining work is wiring plus one genuine design gap — not because it runs.
|
||||
|
||||
What is real about it:
|
||||
|
||||
- Operators **can** author `NodeAcl` rows in the Admin UI (`/clusters/{id}/acls`), and those rows are
|
||||
validated, versioned and persisted.
|
||||
- `ConfigComposer` **does** snapshot every `NodeAcl` row into every deployment artifact, so an ACL
|
||||
edit shifts the artifact's `RevisionHash` and ships to every node.
|
||||
- The node side **never deserializes them** — `DeploymentArtifact` has no ACL reader. The bytes arrive
|
||||
and are dropped.
|
||||
|
||||
So an operator can author a grant, deploy it green, and have it change nothing. That is the behaviour
|
||||
to expect until the tracking issue below is closed.
|
||||
|
||||
**The four things blocking a wire-up** (all verified, none of them merely "call the evaluator"):
|
||||
|
||||
1. **Raw-realm nodes have no representable scope.** `NodeHierarchyKind` has exactly one member,
|
||||
`Equipment`. Raw NodeIds are `Folder→Driver→Device→TagGroup→Tag` RawPaths, which `NodeScope` cannot
|
||||
express — yet the write gate is attached to raw tags. Roughly half the writable address space has
|
||||
no scope to evaluate. This is a design decision, not wiring.
|
||||
2. **The identity carries roles, not groups.** The trie matches `NodeAcl.LdapGroup` against the
|
||||
session's LDAP groups, but `OpcUaUserAuthResult` carries only mapped role strings; the group list
|
||||
is discarded inside `LdapOpcUaUserAuthenticator`.
|
||||
3. **`PermissionTrieBuilder`'s `scopePaths` argument has no producer.** Without it every sub-cluster
|
||||
grant lands in the builder's fallback bucket — the hazard its own comments warn about. It would have
|
||||
to be derived from the artifact's UNS relations.
|
||||
4. **No session lifecycle exists.** `UserAuthorizationState` has the freshness/staleness predicates but
|
||||
nothing in production constructs one, stamps a generation, or drives a refresh.
|
||||
|
||||
Two smaller inconsistencies inside the subsystem itself: `NodeAclScopeKind.FolderSegment` is offered in
|
||||
the authoring dropdown but the trie walker has no level to match it at, and `NodePermissions.AlarmRead`
|
||||
has no `OpcUaOperation` mapping, so it is grantable but unreachable.
|
||||
|
||||
### Hierarchy (design)
|
||||
|
||||
ACLs are evaluated against the node's scope path. `NodeScope` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs`) carries a `Kind` that selects between two hierarchy shapes:
|
||||
|
||||
@@ -245,19 +326,23 @@ The three Write tiers map to Galaxy's v1 `SecurityClassification` — `FreeAcces
|
||||
|
||||
`NodeScope` is described above (Equipment-kind vs SystemPlatform-kind). The evaluator unions the matched grants along the path — a tag-level ACL and an area-level ACL both contribute.
|
||||
|
||||
### Dispatch gate — `IPermissionEvaluator`
|
||||
### Dispatch gate — `IPermissionEvaluator` (design; **no such gate exists**)
|
||||
|
||||
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`. The dispatch path calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call; a `NotGranted` decision denies the operation.
|
||||
`IPermissionEvaluator.Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)` (default impl `TriePermissionEvaluator` at `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs`) returns an `AuthorizationDecision`.
|
||||
|
||||
Key properties:
|
||||
⚠️ **The intended dispatch path — "calls it on every Read, Write, HistoryRead, Browse, Subscribe, AckAlarm, Call" — was never built.** `Authorize` has zero production call sites. What the dispatch path really does is in [What is enforced today](#what-is-enforced-today).
|
||||
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else flows through the evaluator.
|
||||
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. There is no `StrictMode` / fail-open mode; absence of a grant is always a deny.
|
||||
- **Evaluator stays pure.** `TriePermissionEvaluator` has no OPC UA stack dependency — it's tested directly from xUnit.
|
||||
Intended properties, of which only the last is true today:
|
||||
|
||||
- **Driver-agnostic.** No driver-level code participates in authorization decisions. Drivers report `SecurityClassification` as metadata on tag discovery; everything else was to flow through the evaluator.
|
||||
- **Strictly fail-closed (default-deny).** Every guard path returns `NotGranted` — a stale session (past the staleness ceiling, decision #152), a cluster mismatch between session and scope, a missing trie, a pruned bound generation, or simply no matching grant. ⚠️ This describes the evaluator's *internal* behaviour when called. Because nothing calls it, **the effective posture for Read/Browse/Subscribe/HistoryRead is default-ALLOW.**
|
||||
- **Evaluator stays pure.** ✅ True — `TriePermissionEvaluator` has no OPC UA stack dependency and is tested directly from xUnit. That purity is also why it was easy to leave unwired.
|
||||
|
||||
### Full model
|
||||
|
||||
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny).
|
||||
See [`docs/v2/acl-design.md`](v2/acl-design.md) for the complete design: trie invalidation, flag semantics, per-path override rules, and the reasoning behind additive-only (no Deny). Read it as a **design document for unshipped work**, not as a description of running behaviour.
|
||||
|
||||
Note also that the `SystemPlatform` hierarchy kind named above was retired with the v3 dual-namespace address space; `NodeHierarchyKind` now has only `Equipment`.
|
||||
|
||||
### Role grant source (data-plane)
|
||||
|
||||
@@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.:
|
||||
```json
|
||||
"GroupToRole": {
|
||||
"ot-operators": "WriteOperate",
|
||||
"ot-tuners": "WriteTune",
|
||||
"ot-engineers": "WriteConfigure",
|
||||
"ot-alarm-ack": "AlarmAck",
|
||||
"ot-readonly": "ReadOnly"
|
||||
"ot-alarm-ack": "AlarmAck"
|
||||
}
|
||||
```
|
||||
|
||||
If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes
|
||||
and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who
|
||||
authenticate successfully. (The same requirement gates both the scripted-alarm and the native
|
||||
Galaxy-alarm Part-9 ack/confirm/shelve paths.)
|
||||
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
|
||||
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
|
||||
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
|
||||
to `WriteTune` grants nothing, and mapping one to `ReadOnly` restricts nothing (reads are ungated for
|
||||
everyone regardless). Earlier revisions of this document listed all five as "code-true"; that was
|
||||
wrong for three of them.
|
||||
|
||||
The role strings above are **exact, case-insensitive, and code-true** — the inbound gates compare
|
||||
against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings
|
||||
`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not**
|
||||
`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a
|
||||
`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
|
||||
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
|
||||
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
|
||||
the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses,
|
||||
subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry.
|
||||
|
||||
The two live role strings are exact and case-insensitive. In particular the alarm-ack role is
|
||||
`AlarmAck`, **not** `AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different
|
||||
vocabulary); a `GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Admin UI rebuild plan (F15)
|
||||
|
||||
> ⚠️ **Historical (audited 2026-07-27) — describes the v2 AdminUI.** Several named types
|
||||
> (`AuthorizationPolicies`, `IdentificationFields`) no longer exist, and the v3 rebuild replaced the
|
||||
> per-cluster UNS/Equipment/Tags tabs with the global `/uns` page and the `/raw` project tree. Kept
|
||||
> for the record.
|
||||
|
||||
**Status:** UX kickoff — proposals to react to before any per-page rebuild starts.
|
||||
**Last updated:** 2026-05-26 on `v2-akka-fuse`.
|
||||
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ Message contracts are defined; actual SDK calls are stubbed (counters only). Rea
|
||||
|
||||
Both have message contracts wired. Engine integration deferred:
|
||||
|
||||
- `HistorianAdapterActor` — named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink` (F11).
|
||||
- `HistorianAdapterActor` — gRPC to the `ZB.MOM.WW.HistorianGateway` sidecar (the sole historian backend; the named-pipe Wonderware sidecar is retired) + `LocalDbStoreAndForwardSink` (F11; renamed from `SqliteStoreAndForwardSink` when the buffer moved into the consolidated LocalDb).
|
||||
- `PeerOpcUaProbeActor` — real `opc.tcp://peer:4840` ping (F12). Current stub always returns `Ok=true`.
|
||||
|
||||
## DbHealthProbeActor
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# OPC UA Client Authorization (ACL Design) — OtOpcUa v2
|
||||
|
||||
> ⚠️ **NEVER WIRED (verified against source 2026-07-27).** This is a design document for work that was
|
||||
> only half-built. The trie, the builder, the cache and the evaluator all exist in
|
||||
> `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/` and carry 30 passing unit tests — but
|
||||
> `IPermissionEvaluator.Authorize` has **zero production call sites**, nothing registers it in DI, and
|
||||
> `ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj` does not reference the project it lives in. `NodeAcl` rows are
|
||||
> authorable in the Admin UI and are snapshotted into every deployment artifact, but the node side never
|
||||
> deserializes them.
|
||||
>
|
||||
> **Nothing described below is enforced.** For what actually gates OPC UA operations today, see
|
||||
> [`docs/security.md`](../security.md) § Data-Plane Authorization and
|
||||
> [`docs/ReadWriteOperations.md`](../ReadWriteOperations.md). Four blockers stand between this design
|
||||
> and a wire-up (Raw-realm nodes have no `NodeScope` representation; the session carries roles, not LDAP
|
||||
> groups; `PermissionTrieBuilder`'s `scopePaths` has no producer; no session lifecycle exists) — they
|
||||
> are recorded in `deferment.md` §3.1.
|
||||
>
|
||||
> Note also that the `SystemPlatform` hierarchy kind used throughout was retired with the v3
|
||||
> dual-namespace address space.
|
||||
>
|
||||
> **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).
|
||||
>
|
||||
> **Branch**: `v2`
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Driver Stability & Isolation — OtOpcUa v2
|
||||
|
||||
> ⚠️ **The tier assignments below are NOT what runs (verified against source 2026-07-27).**
|
||||
> **Every one of the 12 drivers runs as Tier A.** `DriverFactoryRegistry` defaults `DriverTier tier =
|
||||
> DriverTier.A` and **no factory in `src/Drivers/` passes a tier**, so nothing ever selects B or C. The
|
||||
> Galaxy and FOCAS Tier-C assignments in the table below are aspirational.
|
||||
>
|
||||
> Consequences worth knowing:
|
||||
>
|
||||
> - The **Tier-C-only protections are dormant for every driver** — `MemoryRecycle` gates on
|
||||
> `when _tier == DriverTier.C`, and `ScheduledRecycleScheduler` refuses unless Tier C. Neither has ever
|
||||
> engaged in production. `IDriverSupervisor` has zero implementations, yet the config parser still
|
||||
> validates `RecycleIntervalSeconds`, so an operator can author a recycle interval that does nothing.
|
||||
> - `DriverTypeRegistry` — which this document's model implies is the tier authority — is **vestigial**:
|
||||
> referenced only by its own test, with nothing registering metadata at startup.
|
||||
> - The **separate-Windows-service hosting** described for Tier C does not exist either. Galaxy reaches
|
||||
> MXAccess over gRPC to the external **mxaccessgw** sidecar; the `Galaxy.Proxy`/`Host`/`Shared` pattern
|
||||
> named below was retired in PR 7.2, and FOCAS runs in-process like everything else.
|
||||
>
|
||||
> `docs/drivers/README.md` says Tier A for Galaxy and FOCAS and is the accurate one. Keep-or-delete of the
|
||||
> tier machinery is tracked as Gitea **#522**; this document is retained as the design record.
|
||||
>
|
||||
> **Status**: DRAFT — companion to `plan.md`. Defines the stability tier model, per-driver hosting decisions, cross-cutting protections every driver process must apply, and the canonical worked example (FOCAS) for the high-risk tier.
|
||||
>
|
||||
> **Branch**: `v2`
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Phase 7 Status — Scripting Runtime, Virtual Tags, Scripted Alarms, Historian Sink
|
||||
|
||||
> ⚠️ **Historical (audited 2026-07-27) — describes the v2 codebase; do not treat its present-tense
|
||||
> claims as current.** Many named types no longer exist. Notably `:84` reports four services
|
||||
> ("Done | All four exist in `Admin/Services/`") — **none exist and there is no `Admin/Services/`
|
||||
> directory**; the `Phase7*` composition types were renamed to `AddressSpace*` by `40e8a23e`; and
|
||||
> Gaps 2/3/4 (no `/virtual-tags` page, no `/scripted-alarms` page, no script-log viewer) were closed
|
||||
> by v3's `/uns`, `/alerts` and `/script-log` surfaces. Kept for the record.
|
||||
|
||||
> **Reconciliation date**: 2026-05-18
|
||||
> **Based on**: `docs/v2/implementation/phase-7-scripting-and-alarming.md` (the plan) and
|
||||
> `docs/v2/implementation/exit-gate-phase-7.md` (the exit-gate audit) cross-checked against
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Redundancy Interop Playbook (Phase 6.3 Stream F — task #150)
|
||||
|
||||
> ⚠️ **Historical type names (audited 2026-07-27).** The operator *procedure* below is still the
|
||||
> right one, but `RedundancyPublisherHostedService` (`:22`), `RecoveryStateManager.DwellTime` (`:67`)
|
||||
> and `RedundancyCoordinator` no longer exist — redundancy state is now published by the
|
||||
> cluster-scoped `RedundancyStateActor` singleton with `ServiceLevelCalculator` and
|
||||
> `IRedundancyRoleView`. See `docs/Redundancy.md` (which states plainly that the old types are gone)
|
||||
> and the per-cluster-mesh Phase 6/7 sections of `CLAUDE.md`. The `ServerUriArray` limitation noted
|
||||
> at `:100-105` is still open (SDK object-type gated).
|
||||
|
||||
> **Scope**: manual validation that third-party OPC UA clients + AVEVA MXAccess
|
||||
> observe our non-transparent redundancy signals (ServiceLevel, ServerUriArray,
|
||||
> RedundancySupport) and fail over to the Backup node when the Primary drops.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# v2 Release Readiness
|
||||
|
||||
> ⚠️ **Historical (audited 2026-07-27) — a v2-era snapshot; several "Closed" rows name types that do
|
||||
> not exist in `src/`.** In particular `:41` claims ACL enforcement was "Closed 2026-04-24" via
|
||||
> `AuthorizationBootstrap` / `NodeScopeResolver` / `OpcUaServerService` — **all three have zero
|
||||
> occurrences in `src/`, and there is no per-node ACL gate on any operation** (see `deferment.md`
|
||||
> §3.1 and the banner in `docs/ReadWriteOperations.md`). `RedundancyCoordinator`,
|
||||
> `RedundancyStatePublisher`, `PeerReachabilityTracker`, `GenerationRefreshHostedService`,
|
||||
> `ClusterTopologyLoader` and `SealedBootstrap` are likewise gone. The **unchecked GA exit criteria**
|
||||
> near the end of the file are still live — see `deferment.md` §5.
|
||||
|
||||
> **Last updated**: 2026-04-24 (Phase 5 driver complement closed — AB CIP, AB Legacy, TwinCAT, FOCAS all shipped; FOCAS Tier-C retired for a pure-managed in-process client)
|
||||
> **Status**: **RELEASE-READY (code-path)** for v2 GA. All three original code-path release blockers remain closed. Phase 5 is now complete. Remaining work is manual (live-hardware validations, client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
|
||||
|
||||
@@ -28,11 +37,27 @@ This doc is the single view of where v2 stands against its release criteria. Upd
|
||||
|
||||
All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria.
|
||||
|
||||
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94)
|
||||
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — marked CLOSED 2026-04-19, PR #94)
|
||||
|
||||
> ⚠️ **THIS CLOSURE DOES NOT HOLD (re-verified against source 2026-07-27).** Every type named in this
|
||||
> subsection — `AuthorizationGate`, `NodeScopeResolver`, `AuthorizationBootstrap`, `WriteAuthzPolicy`,
|
||||
> `DriverNodeManager`, `FilterBrowseReferences`, `GateCallMethodRequests`, `MapCallOperation`,
|
||||
> `EquipmentNamespaceContent`, and the `Node:Authorization:*` config keys — has **zero occurrences in
|
||||
> `src/`**. There is no `OnReadValue` hook, no `Browse` override, no `CreateMonitoredItems` override and
|
||||
> no `Call` override in the live node manager, and no HistoryRead path consults any gate.
|
||||
>
|
||||
> Whatever landed on the v2 branch did not survive into the shipped tree. The **only** data-plane
|
||||
> authorization today is two server-wide role checks (`WriteOperate` for writes, `AlarmAck` for alarm
|
||||
> methods); reads, browses, subscriptions and history reads are ungated, and `NodeAcl` rows are
|
||||
> authored and deployed but never evaluated. See [`../security.md`](../security.md) §
|
||||
> Data-Plane Authorization and `deferment.md` §3.1.
|
||||
>
|
||||
> The text below is retained as the historical record of what was *believed* closed. **Read none of it
|
||||
> as current behaviour.**
|
||||
|
||||
**Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
|
||||
|
||||
Remaining Stream C surfaces (hardening, not release-blocking):
|
||||
Remaining Stream C surfaces (hardening, not release-blocking) — **none of these shipped either**:
|
||||
|
||||
- ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired.
|
||||
- ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern).
|
||||
|
||||
@@ -13,6 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
/// <param name="LastError">Latest error message; null when none.</param>
|
||||
/// <param name="ErrorCount5Min">Number of state-transitions into Faulted in the last 5 minutes.</param>
|
||||
/// <param name="PublishedUtc">Timestamp this snapshot was published.</param>
|
||||
/// <param name="RediscoveryNeededUtc">
|
||||
/// When the driver last raised <c>IRediscoverable.OnRediscoveryNeeded</c> — i.e. it observed that the
|
||||
/// remote's tag set may have changed (a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect
|
||||
/// agent restart, a Sparkplug rebirth). Null when the driver has never raised it, or does not
|
||||
/// implement <c>IRediscoverable</c>.
|
||||
/// <para><b>Advisory only.</b> The served address space is NOT rebuilt in response — raw tags are
|
||||
/// authored explicitly through the <c>/raw</c> browse-commit flow. This is a prompt for an operator
|
||||
/// to re-browse the device and commit whatever changed.</para>
|
||||
/// </param>
|
||||
/// <param name="RediscoveryReason">
|
||||
/// The driver-supplied reason string from the same event (e.g. <c>"deploy-time-changed"</c>), shown
|
||||
/// to the operator alongside the prompt. Null when <paramref name="RediscoveryNeededUtc"/> is null.
|
||||
/// </param>
|
||||
public sealed record DriverHealthChanged(
|
||||
string ClusterId,
|
||||
string DriverInstanceId,
|
||||
@@ -20,7 +33,9 @@ public sealed record DriverHealthChanged(
|
||||
DateTime? LastSuccessfulReadUtc,
|
||||
string? LastError,
|
||||
int ErrorCount5Min,
|
||||
DateTime PublishedUtc)
|
||||
DateTime PublishedUtc,
|
||||
DateTime? RediscoveryNeededUtc = null,
|
||||
string? RediscoveryReason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
|
||||
|
||||
@@ -68,6 +68,8 @@ message DriverHealth {
|
||||
optional string last_error = 5; // nullable in the record
|
||||
int32 error_count_5min = 6;
|
||||
google.protobuf.Timestamp published_utc = 7;
|
||||
google.protobuf.Timestamp rediscovery_needed_utc = 8; // DateTime? — absent Timestamp encodes null
|
||||
optional string rediscovery_reason = 9; // nullable in the record
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers.DriverResilienceStatusChanged.
|
||||
|
||||
@@ -19,9 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
|
||||
/// constants and the set the driver factories actually register, so a rename on
|
||||
/// either side breaks the build's test gate. Only constants for
|
||||
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver
|
||||
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own
|
||||
/// constant when its factory is wired in.
|
||||
/// <b>currently-registered</b> factories belong here — a driver adds its own constant
|
||||
/// when its factory is wired in. (This used to name Calculation as the example of a
|
||||
/// not-yet-registered driver; it registers in <c>DriverFactoryBootstrap</c> like the rest.)
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DriverTypeNames
|
||||
|
||||
@@ -15,11 +15,20 @@ public interface IDriverHealthPublisher
|
||||
/// <param name="driverInstanceId">The driver instance the snapshot describes.</param>
|
||||
/// <param name="health">The current health snapshot.</param>
|
||||
/// <param name="errorCount5Min">The number of errors observed in the trailing 5-minute window.</param>
|
||||
/// <param name="rediscoveryNeededUtc">
|
||||
/// When the driver last raised <see cref="IRediscoverable.OnRediscoveryNeeded"/>, or null if never
|
||||
/// (or if it is not an <see cref="IRediscoverable"/>). Advisory: it prompts an operator to
|
||||
/// re-browse, and never rebuilds the served address space.
|
||||
/// </param>
|
||||
/// <param name="rediscoveryReason">The driver-supplied reason from that event; null when
|
||||
/// <paramref name="rediscoveryNeededUtc"/> is null.</param>
|
||||
void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min);
|
||||
int errorCount5Min,
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,6 +47,8 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min)
|
||||
int errorCount5Min,
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null)
|
||||
{ /* no-op */ }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,32 @@ public interface ITagDiscovery
|
||||
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
|
||||
/// </summary>
|
||||
bool SupportsOnlineDiscovery => false;
|
||||
|
||||
/// <summary>
|
||||
/// The device-native references this driver's <b>authored</b> tags bind to — the same vocabulary
|
||||
/// <see cref="DiscoverAsync"/> streams as <see cref="DriverAttributeInfo.FullName"/>. Comparing the
|
||||
/// two sets detects that the remote's tag set has drifted from what an operator authored.
|
||||
/// <para><b>Null means "I do not report this", and drift detection is skipped.</b> That is the
|
||||
/// default on purpose: an empty collection legitimately means "no tags authored, so everything
|
||||
/// discovered is new", and the two must not be confused. A driver opting in is asserting that its
|
||||
/// authored refs are directly comparable to what it discovers.</para>
|
||||
/// <para>Only consulted when <see cref="SupportsOnlineDiscovery"/> is true. For a driver that
|
||||
/// replays authored tags from config rather than browsing the backend, the comparison is a
|
||||
/// tautology — it would report "no drift" forever and read as reassurance.</para>
|
||||
/// </summary>
|
||||
IReadOnlyCollection<string>? AuthoredDiscoveryRefs => null;
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="DiscoverAsync"/> re-emits this driver's <b>authored</b> tags into the same
|
||||
/// stream as the device-derived ones — which FOCAS, TwinCAT and AbCip all do, so the browse picker
|
||||
/// shows an operator their existing tags alongside what the device offers.
|
||||
/// <para><b>This makes one half of drift detection structurally undetectable.</b> If authored tags
|
||||
/// are always re-emitted then the discovered set always contains the authored set, so an authored tag
|
||||
/// that has VANISHED from the device can never appear missing. Declaring it here means the detector
|
||||
/// reports only what it can actually see, instead of reporting "nothing vanished" forever and reading
|
||||
/// as reassurance.</para>
|
||||
/// <para>False (the default) means the stream is purely device-derived — as MTConnect's is, built
|
||||
/// from the Agent's probe model — so both directions are detectable.</para>
|
||||
/// </summary>
|
||||
bool DiscoveryStreamIncludesAuthoredTags => false;
|
||||
}
|
||||
|
||||
@@ -1036,6 +1036,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Pre-declared tags are emitted under their <c>Name</c>, which is the driver FullName the
|
||||
/// controller-browse leaves also use.</remarks>
|
||||
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
|
||||
_declaredTags.Select(t => t.Name).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>True: <c>DiscoverAsync</c> emits browsed controller symbols AND re-emits every
|
||||
/// pre-declared tag.</remarks>
|
||||
public bool DiscoveryStreamIncludesAuthoredTags => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbLegacyDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
|
||||
private AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly ILogger<AbLegacyDriver> _logger;
|
||||
@@ -94,12 +96,28 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbLegacy";
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
|
||||
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
|
||||
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with, so an operator's edit is discarded while the deployment still seals green.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
||||
|
||||
@@ -49,6 +49,23 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
|
||||
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
return new AbLegacyDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
tagFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an AB Legacy <c>DriverConfig</c> JSON document into typed options. Extracted so
|
||||
/// <see cref="AbLegacyDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
|
||||
/// (Gitea #516) instead of serving the options it was constructed with — which silently discarded
|
||||
/// every operator edit while the deployment still sealed green.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>The parsed <see cref="AbLegacyDriverOptions"/>.</returns>
|
||||
internal static AbLegacyDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -82,10 +99,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
};
|
||||
|
||||
return new AbLegacyDriver(
|
||||
options, driverInstanceId,
|
||||
tagFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
|
||||
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
|
||||
private FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IFocasClientFactory _clientFactory;
|
||||
/// <summary>Mutable for the same reason as <see cref="_options"/> — the <c>Backend</c> config key
|
||||
/// selects it, so the two must move together or a new tag set polls through an old backend.</summary>
|
||||
private IFocasClientFactory _clientFactory;
|
||||
|
||||
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
|
||||
/// was constructed directly (tests, or a caller supplying its own backend), which keeps the
|
||||
/// constructor-supplied options authoritative — exactly the prior behaviour.</summary>
|
||||
private readonly Func<string, FocasDriverBinding>? _rebind;
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly ILogger<FocasDriver> _logger;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -58,14 +67,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
|
||||
/// <param name="logger">Optional logger instance.</param>
|
||||
/// <param name="rebind">Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency,
|
||||
/// supplied by the factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct
|
||||
/// construction, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied
|
||||
/// options + backend.</param>
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null,
|
||||
ILogger<FocasDriver>? logger = null)
|
||||
ILogger<FocasDriver>? logger = null,
|
||||
Func<string, FocasDriverBinding>? rebind = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||
_rebind = rebind;
|
||||
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
@@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
backoffCap: PollBackoffCap);
|
||||
}
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
|
||||
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
|
||||
/// without a config — those keep the constructor-supplied options + backend.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -100,12 +125,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Opens the configured CNC handles and builds the authored tag table.
|
||||
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
|
||||
/// the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
|
||||
/// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
|
||||
/// and are adopted together. That pairing is the point — adopting options alone would poll a NEW
|
||||
/// device/tag set through the OLD backend, which is why this driver was excluded from the first
|
||||
/// #516 pass.</para>
|
||||
/// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
|
||||
/// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
|
||||
/// remains the outer guarantee.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
{
|
||||
// #516: adopt a changed config. Options and the backend client factory are re-derived by ONE
|
||||
// parse and assigned together — a partial adoption would poll a new device/tag set through the
|
||||
// old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the
|
||||
// constructor-supplied pair, which is what every lifecycle test passing "{}" relies on.
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
_options = binding.Options;
|
||||
_clientFactory = binding.ClientFactory;
|
||||
}
|
||||
|
||||
// Fail fast if the factory is a stub/unimplemented backend — the operator must
|
||||
// see an actionable error at init rather than a phantom-Healthy driver that fails
|
||||
// every read/write/subscribe silently.
|
||||
@@ -467,6 +517,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// until the node set is non-empty and stable.</summary>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The authored tag's <c>Name</c> IS its driver-side FullName — <c>DiscoverAsync</c> emits
|
||||
/// <c>FullName: tag.Name</c> for every authored tag — so the two sets are directly comparable.</remarks>
|
||||
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
|
||||
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>True: <c>DiscoverAsync</c> emits the device-derived FixedTree AND re-emits every authored
|
||||
/// tag, so an authored tag that vanished from the CNC cannot appear missing.</remarks>
|
||||
public bool DiscoveryStreamIncludesAuthoredTags => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions
|
||||
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
||||
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
|
||||
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
var binding = ParseBinding(driverInstanceId, driverConfigJson);
|
||||
return new FocasDriver(
|
||||
binding.Options,
|
||||
driverInstanceId,
|
||||
binding.ClientFactory,
|
||||
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
|
||||
// config-derived dependency on a reinitialize — options AND the backend client factory
|
||||
// together (#516). Re-parsing options alone would run a new device/tag set against the old
|
||||
// backend, which is worse than not re-parsing at all.
|
||||
rebind: json => ParseBinding(driverInstanceId, json));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a FOCAS <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
|
||||
/// from config: the typed options and the <see cref="IFocasClientFactory"/> the <c>Backend</c> key
|
||||
/// selects. Returned together because they must be adopted together — see the <c>rebind</c> note in
|
||||
/// <see cref="CreateInstance"/>.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
||||
/// <returns>The parsed options + backend factory.</returns>
|
||||
internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions
|
||||
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
|
||||
};
|
||||
|
||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions
|
||||
public TimeSpan? Interval { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything <c>FocasDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
|
||||
/// reinitialize adopts all of it atomically.
|
||||
/// <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
|
||||
/// <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
|
||||
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
|
||||
/// </summary>
|
||||
/// <param name="Options">The typed driver options.</param>
|
||||
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
|
||||
public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory);
|
||||
|
||||
@@ -907,6 +907,19 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
/// </remarks>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The DataItem ids the authored raw tags bind. <c>DiscoverAsync</c> emits
|
||||
/// <c>FullName: dataItem.Id</c> from the Agent's probe model, so the two sets are the same
|
||||
/// vocabulary.</remarks>
|
||||
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
|
||||
Volatile.Read(ref _binding).RawPathsByDataItemId.Keys;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>False — <c>DiscoverAsync</c> is built purely from the Agent's probe model and never
|
||||
/// re-emits authored tags, so an authored DataItem that the Agent stopped publishing IS visible as
|
||||
/// missing. MTConnect is currently the only driver where both drift directions are detectable.</remarks>
|
||||
public bool DiscoveryStreamIncludesAuthoredTags => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
|
||||
|
||||
@@ -23,7 +23,9 @@ public sealed class ModbusDriver
|
||||
{
|
||||
// ---- instance fields (grouped at top for auditability) ----
|
||||
|
||||
private readonly ModbusDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/transport uses it.</summary>
|
||||
private ModbusDriverOptions _options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ILogger<ModbusDriver> _logger;
|
||||
@@ -188,12 +190,29 @@ public sealed class ModbusDriver
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
|
||||
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
|
||||
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with, so an operator's edit is discarded while the deployment still seals green. An empty /
|
||||
// placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
_transport = _transportFactory(_options);
|
||||
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
|
||||
|
||||
@@ -44,6 +44,23 @@ public static class ModbusDriverFactoryExtensions
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
|
||||
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
return new ModbusDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
transportFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Modbus <c>DriverConfig</c> JSON document into typed options. Extracted from
|
||||
/// <see cref="CreateInstance(string,string,ILoggerFactory?)"/> so <see cref="ModbusDriver.InitializeAsync"/>
|
||||
/// can re-parse a CHANGED config on reinitialize (Gitea #516) rather than serving the options it was
|
||||
/// constructed with — which silently discarded every edit while the deployment still sealed green.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||
/// <returns>The parsed <see cref="ModbusDriverOptions"/>.</returns>
|
||||
internal static ModbusDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -106,10 +123,7 @@ public static class ModbusDriverFactoryExtensions
|
||||
},
|
||||
};
|
||||
|
||||
return new ModbusDriver(
|
||||
options, driverInstanceId,
|
||||
transportFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
||||
return options;
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
||||
|
||||
@@ -54,7 +54,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
}
|
||||
|
||||
private readonly OpcUaClientDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
|
||||
private OpcUaClientDriverOptions _options;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
private readonly string _driverInstanceId;
|
||||
// ---- IAlarmSource state ----
|
||||
@@ -156,12 +158,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "OpcUaClient";
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
|
||||
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
|
||||
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with — the endpoint, security policy and tag set were all frozen at construction, and only
|
||||
// secret rotation was picked up.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = OpcUaClientDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
// Enforce the Equipment-vs-SystemPlatform choice at startup per driver-specs.md
|
||||
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
|
||||
// not as a runtime surprise.
|
||||
|
||||
+20
-5
@@ -63,16 +63,31 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
public static OpcUaClientDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
return new OpcUaClientDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||
secretResolver ?? NullSecretResolver.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an OpcUaClient <c>DriverConfig</c> JSON document into typed options. Extracted so
|
||||
/// <see cref="OpcUaClientDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
|
||||
/// (Gitea #516) instead of serving the options it was constructed with. Note the driver's own
|
||||
/// doc-comment previously claimed "resolving on every InitializeAsync picks up rotations" — that was
|
||||
/// true of SECRET rotation only; the endpoint, security policy and tag set were all frozen at
|
||||
/// construction.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>The parsed <see cref="OpcUaClientDriverOptions"/>.</returns>
|
||||
internal static OpcUaClientDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||
return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
return new OpcUaClientDriver(
|
||||
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||
secretResolver ?? NullSecretResolver.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,19 @@ internal sealed class SqlBrowseSession : IBrowseSession
|
||||
Name: column.Name,
|
||||
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
||||
IsArray: false,
|
||||
SecurityClass: ReadOnlySecurityClass),
|
||||
SecurityClass: ReadOnlySecurityClass,
|
||||
IsAlarm: false,
|
||||
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
|
||||
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
|
||||
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
|
||||
// schema is carried separately so the commit mapper can qualify the table itself rather
|
||||
// than parsing a joined string back apart.
|
||||
AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["schema"] = reference.Schema,
|
||||
["table"] = reference.Table!,
|
||||
["columnName"] = column.Name,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -51,16 +52,19 @@ public sealed class SqlDriver
|
||||
|
||||
// ---- instance fields (grouped at top for auditability) ----
|
||||
|
||||
private readonly SqlDriverOptions _options;
|
||||
/// <summary>Config-derived, so mutable — see <see cref="ApplyBinding"/>.</summary>
|
||||
private SqlDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ISqlDialect _dialect;
|
||||
private readonly DbProviderFactory _factory;
|
||||
/// <summary>Config-derived, so mutable: <see cref="ApplyBinding"/> swaps it with the connection string
|
||||
/// and options together on a reinitialize (#516).</summary>
|
||||
private ISqlDialect _dialect;
|
||||
private DbProviderFactory _factory;
|
||||
|
||||
/// <summary>
|
||||
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
|
||||
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
|
||||
/// </summary>
|
||||
private readonly string _connectionString;
|
||||
private string _connectionString;
|
||||
|
||||
private readonly ILogger<SqlDriver> _logger;
|
||||
|
||||
@@ -93,11 +97,19 @@ public sealed class SqlDriver
|
||||
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
|
||||
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
|
||||
|
||||
/// <summary>The factory the CALLER passed, or null to take the dialect's. Kept separate from
|
||||
/// <see cref="_factory"/> so a re-derived dialect cannot silently displace a test's injected factory.</summary>
|
||||
private readonly DbProviderFactory? _explicitFactory;
|
||||
|
||||
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
|
||||
/// was constructed directly, which keeps the constructor-supplied trio authoritative.</summary>
|
||||
private readonly Func<string, SqlDriverBinding>? _rebind;
|
||||
|
||||
/// <summary>
|
||||
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
|
||||
/// delegate must read this driver's live authored table, which does not exist before the driver does.
|
||||
/// </summary>
|
||||
private readonly SqlPollReader _reader;
|
||||
private SqlPollReader _reader;
|
||||
|
||||
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
|
||||
private readonly PollGroupEngine _poll;
|
||||
@@ -133,6 +145,11 @@ public sealed class SqlDriver
|
||||
/// explicitly by tests that need to observe or delay connection creation.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional; defaults to a no-op logger.</param>
|
||||
/// <param name="rebind">
|
||||
/// Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency, supplied by the
|
||||
/// factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct construction —
|
||||
/// every test) keeps the constructor-supplied options, dialect and connection string.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
|
||||
public SqlDriver(
|
||||
@@ -141,7 +158,8 @@ public sealed class SqlDriver
|
||||
ISqlDialect dialect,
|
||||
string connectionString,
|
||||
DbProviderFactory? factory = null,
|
||||
ILogger<SqlDriver>? logger = null)
|
||||
ILogger<SqlDriver>? logger = null,
|
||||
Func<string, SqlDriverBinding>? rebind = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
@@ -149,15 +167,47 @@ public sealed class SqlDriver
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
|
||||
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_dialect = dialect;
|
||||
_factory = factory ?? dialect.Factory;
|
||||
_connectionString = connectionString;
|
||||
_logger = logger ?? NullLogger<SqlDriver>.Instance;
|
||||
Endpoint = DescribeEndpoint(connectionString);
|
||||
|
||||
_explicitFactory = factory;
|
||||
_rebind = rebind;
|
||||
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
|
||||
|
||||
// Everything below this line is derived from config and is re-derived wholesale on a reinitialize.
|
||||
ApplyBinding(options, dialect, connectionString);
|
||||
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
||||
onError: HandlePollError,
|
||||
backoffCap: PollBackoffCap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adopts a config-derived trio — options, dialect, connection string — and rebuilds everything
|
||||
/// downstream of it (the provider factory, the endpoint description, and the poll reader, which
|
||||
/// captures all three). Called from the constructor and again from <see cref="InitializeAsync"/>
|
||||
/// when a changed config arrives.
|
||||
/// <para><b>All-or-nothing on purpose.</b> The reason Sql was excluded from the first #516 pass is
|
||||
/// that adopting options alone would poll a NEW tag set through the OLD connection and dialect. Doing
|
||||
/// it in one method means a future field derived from config gets added here rather than being
|
||||
/// forgotten on the reinit path.</para>
|
||||
/// <para>Not thread-safe by design: only the init path calls it, before any poll group is running.
|
||||
/// The poll engine and the tag resolver survive across a rebind — the engine holds a delegate to
|
||||
/// <c>ReadAsync</c> and the resolver reads the live tag table, so neither captures the old trio.</para>
|
||||
/// </summary>
|
||||
/// <param name="options">The typed options to adopt.</param>
|
||||
/// <param name="dialect">The provider seam to adopt.</param>
|
||||
/// <param name="connectionString">The resolved connection string to adopt. Never logged.</param>
|
||||
[MemberNotNull(nameof(_options), nameof(_dialect), nameof(_factory), nameof(_connectionString), nameof(_reader))]
|
||||
private void ApplyBinding(SqlDriverOptions options, ISqlDialect dialect, string connectionString)
|
||||
{
|
||||
_options = options;
|
||||
_dialect = dialect;
|
||||
_factory = _explicitFactory ?? dialect.Factory;
|
||||
_connectionString = connectionString;
|
||||
Endpoint = DescribeEndpoint(connectionString);
|
||||
_reader = new SqlPollReader(
|
||||
_factory,
|
||||
connectionString,
|
||||
@@ -168,12 +218,16 @@ public sealed class SqlDriver
|
||||
nullIsBad: options.NullIsBad,
|
||||
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
|
||||
logger: _logger);
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
||||
onError: HandlePollError,
|
||||
backoffCap: PollBackoffCap);
|
||||
}
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
|
||||
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
|
||||
/// without a config — those keep the constructor-supplied trio.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -187,7 +241,7 @@ public sealed class SqlDriver
|
||||
/// the connection string names both. This is the only rendering of the connection string that may
|
||||
/// appear in a log, a health message, or the Admin UI.
|
||||
/// </summary>
|
||||
public string Endpoint { get; }
|
||||
public string Endpoint { get; private set; } = "";
|
||||
|
||||
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
|
||||
public string HostName => Endpoint;
|
||||
@@ -215,9 +269,20 @@ public sealed class SqlDriver
|
||||
|
||||
/// <summary>
|
||||
/// Builds the authored RawPath table and proves the database is reachable.
|
||||
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
|
||||
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
|
||||
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
|
||||
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
|
||||
/// the factory (Gitea #516). The original comment on this method claimed it was not, on the premise
|
||||
/// that "config parsing belongs to the factory, which builds a fresh instance" — that was false when
|
||||
/// written, because the host reinitialized the EXISTING child in place and every config edit was
|
||||
/// silently discarded while the deployment still sealed green.</para>
|
||||
/// <para>The re-parse is <b>all-or-nothing</b> via <see cref="ApplyBinding"/>: options, the
|
||||
/// <see cref="ISqlDialect"/> and the resolved connection string are re-derived by one
|
||||
/// <c>ParseBinding</c> call and adopted together. That is what makes it safe here — adopting options
|
||||
/// alone would poll a NEW tag set through the OLD database, which is why this driver was excluded
|
||||
/// from the first #516 pass. It happens BEFORE <c>BuildTagTable</c>, so the tag table and the
|
||||
/// connection it is polled over always come from the same revision.</para>
|
||||
/// <para>A driver constructed directly (every unit test) gets no rebinder and keeps its
|
||||
/// constructor-supplied trio, so <c>"{}"</c>-passing lifecycle tests are unaffected.
|
||||
/// <c>DriverSpawnPlanner</c>'s stop + respawn remains the outer guarantee.</para>
|
||||
/// <para>The table is built first because it is pure and cannot fail; the liveness check is the only
|
||||
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
|
||||
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
|
||||
@@ -235,6 +300,18 @@ public sealed class SqlDriver
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
|
||||
// #516: adopt a changed config BEFORE the tag table is built, so the table and the connection it
|
||||
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
|
||||
// connection string as one unit; adopting options alone would poll a NEW tag set through the OLD
|
||||
// database, which is why this driver was excluded from the first pass. A null rebinder (direct
|
||||
// construction) or an empty/placeholder document keeps the constructor-supplied trio.
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
|
||||
}
|
||||
|
||||
BuildTagTable();
|
||||
|
||||
try
|
||||
|
||||
@@ -81,6 +81,43 @@ public static class SqlDriverFactoryExtensions
|
||||
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Sql <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
|
||||
/// from config: the typed options, the <see cref="ISqlDialect"/> the <c>provider</c> key selects, and
|
||||
/// the connection string resolved from <c>connectionStringRef</c>.
|
||||
/// <para>Returned together because they must be adopted together. Re-parsing options alone on a
|
||||
/// reinitialize would run a NEW tag set against the OLD connection and dialect (Gitea #516) — a
|
||||
/// half-applied config change is worse than a discarded one, because it looks like it worked.</para>
|
||||
/// <para>Re-running this on reinitialize also re-resolves the connection string from its environment
|
||||
/// variable, so a rotated credential is picked up without a process restart.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON.</param>
|
||||
/// <returns>The parsed options, dialect and resolved connection string.</returns>
|
||||
/// <exception cref="InvalidOperationException">The config is malformed or names no connection string.</exception>
|
||||
internal static SqlDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = Deserialize(driverInstanceId, driverConfigJson);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
|
||||
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
|
||||
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
|
||||
}
|
||||
|
||||
var dialect = CreateDialect(dto.Provider, driverInstanceId);
|
||||
|
||||
// Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path
|
||||
// that HAS the secret in hand — which is exactly where a careless interpolation would leak it.
|
||||
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
|
||||
return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
|
||||
/// <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
|
||||
@@ -102,25 +139,11 @@ public static class SqlDriverFactoryExtensions
|
||||
public static SqlDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = Deserialize(driverInstanceId, driverConfigJson);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
|
||||
$"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
|
||||
$"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
|
||||
}
|
||||
|
||||
var dialect = CreateDialect(dto.Provider, driverInstanceId);
|
||||
|
||||
// Resolved BEFORE the knob validation below, so that every subsequent throw is a path that HAS the
|
||||
// secret in hand — which is exactly where a careless interpolation would leak it.
|
||||
var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
|
||||
var options = BuildOptions(dto, driverInstanceId);
|
||||
var binding = ParseBinding(driverInstanceId, driverConfigJson);
|
||||
var dto = binding.Dto;
|
||||
var dialect = binding.Dialect;
|
||||
var connectionString = binding.ConnectionString;
|
||||
var options = binding.Options;
|
||||
|
||||
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
|
||||
?? NullLogger.Instance;
|
||||
@@ -143,7 +166,11 @@ public static class SqlDriverFactoryExtensions
|
||||
dialect,
|
||||
connectionString,
|
||||
factory: null,
|
||||
logger: loggerFactory?.CreateLogger<SqlDriver>());
|
||||
logger: loggerFactory?.CreateLogger<SqlDriver>(),
|
||||
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive options,
|
||||
// dialect and connection string together on a reinitialize (#516). Re-resolving the connection
|
||||
// string also picks up a rotated credential without a process restart.
|
||||
rebind: json => ParseBinding(driverInstanceId, json));
|
||||
|
||||
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
|
||||
// permitted outside the provider call.
|
||||
@@ -250,3 +277,21 @@ public static class SqlDriverFactoryExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything <c>SqlDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
|
||||
/// reinitialize adopts all of it atomically.
|
||||
/// <para>Partial adoption is the hazard this type exists to prevent: the dialect and the resolved
|
||||
/// connection string are both config-derived, so adopting new <see cref="Options"/> alone would poll a
|
||||
/// NEW tag set against the OLD database (Gitea #516).</para>
|
||||
/// </summary>
|
||||
/// <param name="Options">The typed driver options.</param>
|
||||
/// <param name="Dialect">The provider seam the <c>provider</c> key selects.</param>
|
||||
/// <param name="ConnectionString">The connection string resolved from <c>connectionStringRef</c>. Never logged.</param>
|
||||
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
|
||||
/// <c>allowWrites</c>, which the driver ignores but warns about) need not re-deserialize.</param>
|
||||
public sealed record SqlDriverBinding(
|
||||
SqlDriverOptions Options,
|
||||
ISqlDialect Dialect,
|
||||
string ConnectionString,
|
||||
SqlDriverConfigDto Dto);
|
||||
|
||||
@@ -404,6 +404,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The authored tag's <c>Name</c> is its RawPath and its driver FullName
|
||||
/// (<c>DiscoverAsync</c> emits <c>FullName: tag.Name</c>), directly comparable to the browsed
|
||||
/// symbols' <c>InstancePath</c>.</remarks>
|
||||
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs =>
|
||||
_tagsByRawPath.Values.Select(t => t.Name).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>True: <c>DiscoverAsync</c> emits browsed ADS symbols AND re-emits every authored tag.</remarks>
|
||||
public bool DiscoveryStreamIncludesAuthoredTags => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -28,6 +28,19 @@ else if (!IsNew && _existing is null)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>This grant will not be enforced.</strong>
|
||||
It is saved and shipped in every deployment artifact, but the OPC UA server never evaluates
|
||||
ACL rows — the permission evaluator has no production call site. Nothing you set below will
|
||||
change what a client can read, write, browse or acknowledge.
|
||||
<br />
|
||||
Real access control today is fleet-wide LDAP-group → role mapping
|
||||
(<span class="mono">Security:Ldap:GroupToRole</span>):
|
||||
<span class="mono">WriteOperate</span> to write any tag,
|
||||
<span class="mono">AlarmAck</span> to acknowledge any alarm. Reads are ungated.
|
||||
See <span class="mono">docs/security.md</span>.
|
||||
</div>
|
||||
|
||||
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
|
||||
<DataAnnotationsValidator />
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
|
||||
@@ -19,6 +19,19 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>These rules are not enforced.</strong>
|
||||
ACL rows are saved and shipped in every deployment artifact, but the OPC UA server never
|
||||
evaluates them — the permission evaluator has no production call site. Authoring a grant here
|
||||
changes nothing about what a client can read or write.
|
||||
<br />
|
||||
What <em>is</em> enforced today is coarse and fleet-wide: a client needs the
|
||||
<span class="mono">WriteOperate</span> role to write any tag and
|
||||
<span class="mono">AlarmAck</span> to acknowledge any alarm, both mapped from LDAP groups via
|
||||
<span class="mono">Security:Ldap:GroupToRole</span>. Reads, browses, subscriptions and history
|
||||
reads are <strong>not</strong> restricted at all. See <span class="mono">docs/security.md</span>.
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope
|
||||
(a folder, an equipment, a tag). Per-cluster role grants were dropped in favour of
|
||||
|
||||
@@ -191,6 +191,16 @@ else
|
||||
{
|
||||
<span class="mono small">@d.DriverInstanceId</span>
|
||||
}
|
||||
@if (d.RediscoveryNeededUtc is not null)
|
||||
{
|
||||
@* The driver reported its remote's tag set may have changed. Advisory
|
||||
only — v3 authors raw tags via /raw browse-commit, so nothing is
|
||||
grafted at runtime and an operator must re-browse to pick it up. *@
|
||||
<span class="chip chip-warn ms-1"
|
||||
title="@($"Reported {d.RediscoveryNeededUtc:u}: {d.RediscoveryReason ?? "tag set may have changed"}. The served address space is unchanged — re-browse this device under /raw and commit anything new.")">
|
||||
re-browse
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>@(d.DriverType ?? "—")</td>
|
||||
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
|
||||
|
||||
@@ -39,38 +39,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@switch (_driverType)
|
||||
@* Rendered from DeviceFormMap — see DriverConfigFormMap for why this is a map and
|
||||
not a @switch. Drivers in DeviceFormMap.SingleConnectionDriverTypes legitimately
|
||||
have no per-device form; the parity test consults that set. *@
|
||||
@if (DeviceFormMap.Resolve(_driverType) is { } deviceFormType)
|
||||
{
|
||||
<DynamicComponent Type="deviceFormType" Parameters="_deviceFormParameters" />
|
||||
}
|
||||
else if (DeviceFormMap.IsSingleConnection(_driverType))
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
This driver holds a single connection, authored on the <strong>driver</strong>.
|
||||
There is nothing to configure per device.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
case DriverTypeNames.Modbus:
|
||||
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.S7:
|
||||
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.AbCip:
|
||||
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.AbLegacy:
|
||||
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.TwinCAT:
|
||||
<TwinCATDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.FOCAS:
|
||||
<FocasDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.OpcUaClient:
|
||||
<OpcUaClientDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.Galaxy:
|
||||
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
case DriverTypeNames.Mqtt:
|
||||
<MqttDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -120,6 +105,14 @@
|
||||
private string _name = "";
|
||||
private bool _enabled = true;
|
||||
private string _deviceConfigJson = "{}";
|
||||
|
||||
/// <summary>Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render —
|
||||
/// see DriverConfigModal for why this must not be cached.</summary>
|
||||
private Dictionary<string, object> _deviceFormParameters => new()
|
||||
{
|
||||
["DeviceConfigJson"] = _deviceConfigJson,
|
||||
["DeviceConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _deviceConfigJson = v),
|
||||
};
|
||||
private byte[] _rowVersion = [];
|
||||
private string _parentDriverConfig = "{}";
|
||||
|
||||
|
||||
+26
-40
@@ -31,50 +31,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@switch (_driverType)
|
||||
@* Rendered from DriverConfigFormMap rather than a @switch: a Razor switch compiles
|
||||
into BuildRenderTree's IL, so no test can enumerate its cases — which is how this
|
||||
modal came to be missing a Calculation arm while the driver-picker guard stayed
|
||||
green (deferment.md G-1/G-4). The map is guarded by DriverFormMapParityTests. *@
|
||||
@if (DriverConfigFormMap.Resolve(_driverType) is { } formType)
|
||||
{
|
||||
<DynamicComponent Type="formType" Parameters="_formParameters" />
|
||||
}
|
||||
else
|
||||
{
|
||||
case DriverTypeNames.Modbus:
|
||||
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.S7:
|
||||
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.AbCip:
|
||||
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.AbLegacy:
|
||||
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.TwinCAT:
|
||||
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.FOCAS:
|
||||
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.OpcUaClient:
|
||||
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.Galaxy:
|
||||
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.Sql:
|
||||
<SqlDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.Mqtt:
|
||||
<MqttDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
case DriverTypeNames.MTConnect:
|
||||
<MTConnectDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
|
||||
break;
|
||||
default:
|
||||
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
|
||||
break;
|
||||
}
|
||||
|
||||
@* Galaxy + MQTT hold ONE connection per driver instance, so they author it here and
|
||||
their device forms are informational. Every other driver splits the endpoint onto
|
||||
the device (the v3 endpoint→DeviceConfig split). *@
|
||||
@if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt)
|
||||
@* Some drivers hold ONE connection per driver instance and author it here; the rest
|
||||
split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is
|
||||
DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and
|
||||
so told Sql and MTConnect authors to go find an endpoint field on a device form that
|
||||
does not exist (deferment.md G-3). *@
|
||||
@if (DeviceFormMap.IsSingleConnection(_driverType))
|
||||
{
|
||||
<p class="form-text mt-3 mb-0">
|
||||
This driver holds a single connection, authored above. Test-connect lives on its
|
||||
@@ -133,6 +108,17 @@
|
||||
private string _driverType = "";
|
||||
private string _name = "";
|
||||
private string _driverConfigJson = "{}";
|
||||
|
||||
/// <summary>Parameters handed to the DynamicComponent-rendered driver form. Rebuilt on every render so
|
||||
/// the child receives the CURRENT json — a cached dictionary would pin the value captured at first
|
||||
/// render and silently strand every subsequent edit.</summary>
|
||||
private Dictionary<string, object> _formParameters => new()
|
||||
{
|
||||
["DriverConfigJson"] = _driverConfigJson,
|
||||
["DriverConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _driverConfigJson = v),
|
||||
["ResilienceConfig"] = _resilienceConfig!,
|
||||
["ResilienceConfigChanged"] = EventCallback.Factory.Create<string?>(this, v => _resilienceConfig = v),
|
||||
};
|
||||
private string? _resilienceConfig;
|
||||
private byte[] _rowVersion = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// <c>DriverType → typed driver-config form component</c>, rendered by <c>DriverConfigModal</c> through
|
||||
/// <c><DynamicComponent></c>.
|
||||
/// <para><b>Why this is a map and not the <c>@switch</c> it replaced.</b> A Razor <c>@switch</c> compiles
|
||||
/// into <c>BuildRenderTree</c>'s IL, so no test can enumerate its cases — which is exactly how the modal
|
||||
/// came to be missing a <c>Calculation</c> arm while the sibling driver-picker guard stayed green
|
||||
/// (<c>deferment.md</c> G-1/G-4). The picker test only works because <c>RawDriverTypeDialog</c> keeps its
|
||||
/// data in a field the markup enumerates; this type gives the two config modals the same property, and
|
||||
/// being <c>public</c> it needs none of that test's <c>BindingFlags.NonPublic</c> fragility.</para>
|
||||
/// <para>Modelled on <c>TagConfigEditorMap</c>. Guarded by <c>DriverFormMapParityTests</c>.</para>
|
||||
/// </summary>
|
||||
public static class DriverConfigFormMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Type> Map =
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DriverTypeNames.Modbus] = typeof(ModbusDriverForm),
|
||||
[DriverTypeNames.S7] = typeof(S7DriverForm),
|
||||
[DriverTypeNames.AbCip] = typeof(AbCipDriverForm),
|
||||
[DriverTypeNames.AbLegacy] = typeof(AbLegacyDriverForm),
|
||||
[DriverTypeNames.TwinCAT] = typeof(TwinCATDriverForm),
|
||||
[DriverTypeNames.FOCAS] = typeof(FocasDriverForm),
|
||||
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDriverForm),
|
||||
[DriverTypeNames.Galaxy] = typeof(GalaxyDriverForm),
|
||||
[DriverTypeNames.Sql] = typeof(SqlDriverForm),
|
||||
[DriverTypeNames.Mqtt] = typeof(MqttDriverForm),
|
||||
[DriverTypeNames.MTConnect] = typeof(MTConnectDriverForm),
|
||||
[DriverTypeNames.Calculation] = typeof(CalculationDriverForm),
|
||||
};
|
||||
|
||||
/// <summary>The driver types that have a typed config form.</summary>
|
||||
public static IReadOnlyCollection<string> MappedDriverTypes => (IReadOnlyCollection<string>)Map.Keys;
|
||||
|
||||
/// <summary>Resolves the form component for a driver type, or null when none is mapped (the modal
|
||||
/// then renders its "no typed config form" warning rather than crashing).</summary>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
/// <returns>The form component type, or <see langword="null"/>.</returns>
|
||||
public static Type? Resolve(string? driverType) =>
|
||||
driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>DriverType → typed device-config form component</c>, rendered by <c>DeviceModal</c>.
|
||||
/// <para><b>Not every driver has one, by design.</b> The types in
|
||||
/// <see cref="SingleConnectionDriverTypes"/> hold ONE connection per driver instance and author it on the
|
||||
/// driver form, so a per-device endpoint editor would be meaningless. The parity test consults that set
|
||||
/// rather than demanding total coverage — which keeps it a real guard instead of one that has to be
|
||||
/// suppressed.</para>
|
||||
/// </summary>
|
||||
public static class DeviceFormMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, Type> Map =
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DriverTypeNames.Modbus] = typeof(ModbusDeviceForm),
|
||||
[DriverTypeNames.S7] = typeof(S7DeviceForm),
|
||||
[DriverTypeNames.AbCip] = typeof(AbCipDeviceForm),
|
||||
[DriverTypeNames.AbLegacy] = typeof(AbLegacyDeviceForm),
|
||||
[DriverTypeNames.TwinCAT] = typeof(TwinCATDeviceForm),
|
||||
[DriverTypeNames.FOCAS] = typeof(FocasDeviceForm),
|
||||
[DriverTypeNames.OpcUaClient] = typeof(OpcUaClientDeviceForm),
|
||||
[DriverTypeNames.Galaxy] = typeof(GalaxyDeviceForm),
|
||||
[DriverTypeNames.Mqtt] = typeof(MqttDeviceForm),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Driver types that hold a single connection at the DRIVER level, so they legitimately have no
|
||||
/// per-device form. Galaxy and Mqtt author a connection on the driver form and keep an informational
|
||||
/// device form; Sql (one connection string), MTConnect (one Agent URI) and Calculation (no
|
||||
/// connection at all) have no device form to render.
|
||||
/// <para>Read by <c>DriverConfigModal</c> so the operator is told where the endpoint actually lives —
|
||||
/// it previously hardcoded Galaxy-or-Mqtt and told Sql/MTConnect authors to go and find an endpoint
|
||||
/// field on the device that does not exist (<c>deferment.md</c> G-3).</para>
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> SingleConnectionDriverTypes =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
DriverTypeNames.Galaxy,
|
||||
DriverTypeNames.Mqtt,
|
||||
DriverTypeNames.Sql,
|
||||
DriverTypeNames.MTConnect,
|
||||
DriverTypeNames.Calculation,
|
||||
};
|
||||
|
||||
/// <summary>The driver types that have a typed device form.</summary>
|
||||
public static IReadOnlyCollection<string> MappedDriverTypes => (IReadOnlyCollection<string>)Map.Keys;
|
||||
|
||||
/// <summary>Resolves the device form component for a driver type, or null when none is mapped.</summary>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
/// <returns>The form component type, or <see langword="null"/>.</returns>
|
||||
public static Type? Resolve(string? driverType) =>
|
||||
driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
|
||||
|
||||
/// <summary>True when this driver authors its connection on the DRIVER form rather than per-device.</summary>
|
||||
/// <param name="driverType">The driver type name.</param>
|
||||
/// <returns><see langword="true"/> when the connection is driver-level.</returns>
|
||||
public static bool IsSingleConnection(string? driverType) =>
|
||||
driverType is not null && SingleConnectionDriverTypes.Contains(driverType);
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
@* Embeddable Calculation pseudo-driver config form body. The Calculation driver has NO connection of any
|
||||
kind — it computes signal-level tags from other tags' RawPaths — so its only driver-level knob is the
|
||||
per-run script timeout. rawTags is composer-owned and never authored here.
|
||||
|
||||
Closes deferment.md G-1: Calculation was offered in the /raw driver picker and registered by the factory,
|
||||
but DriverConfigModal had no arm for it, so it fell through to the "No typed config form" warning and
|
||||
RunTimeout was unauthorable through the UI. Hosted by DriverConfigModal via DriverConfigFormMap. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Serialization
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
|
||||
|
||||
<section class="panel rise mt-3" style="animation-delay:.06s">
|
||||
<div class="panel-head">Evaluation</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Run timeout (ms)</label>
|
||||
<InputNumber @bind-Value="_form.RunTimeoutMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">
|
||||
Per-evaluation deadline for a calculated tag's script. Blank = the driver default (2000 ms).
|
||||
A script exceeding it is abandoned and the tag publishes Bad.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-text mt-3 mb-0">
|
||||
This driver has no connection to author — it reads other tags by RawPath. Its calculated tags and
|
||||
their scripts are authored on the tags themselves under <strong>/raw</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
|
||||
|
||||
@code {
|
||||
/// <summary>The driver-level DriverConfig JSON.</summary>
|
||||
[Parameter] public string DriverConfigJson { get; set; } = "{}";
|
||||
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
|
||||
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
|
||||
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
|
||||
[Parameter] public string? ResilienceConfig { get; set; }
|
||||
/// <summary>Fired when the resilience overrides change.</summary>
|
||||
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
|
||||
|
||||
// camelCase + omit-nulls, matching the driver's case-insensitive deserialize. Omitting runTimeoutMs
|
||||
// means "use the driver default" rather than pinning the current default into the blob.
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private FormModel _form = new();
|
||||
private string? _lastParsed;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Re-parse only when the inbound value actually changed, so an in-progress edit is not clobbered.
|
||||
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
|
||||
{
|
||||
_form = new FormModel { RunTimeoutMs = TryDeserialize(DriverConfigJson)?.RunTimeoutMs };
|
||||
_lastParsed = DriverConfigJson;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serializes the current config to camelCase JSON. rawTags is never emitted — the composer
|
||||
/// adds it at deploy time.</summary>
|
||||
public string GetConfigJson() => JsonSerializer.Serialize(new Dto { RunTimeoutMs = _form.RunTimeoutMs }, _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
var json = GetConfigJson();
|
||||
_lastParsed = json;
|
||||
await DriverConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(string? r)
|
||||
{
|
||||
ResilienceConfig = r;
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static Dto? TryDeserialize(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<Dto>(json, _jsonOpts); }
|
||||
catch (JsonException) { return null; } // a hand-edited blob must not crash the modal
|
||||
}
|
||||
|
||||
/// <summary>Mirrors the driver's own config DTO. rawTags is deliberately absent — it is composer-owned,
|
||||
/// and round-tripping it through this form would let the editor drop tags on save.</summary>
|
||||
private sealed class Dto
|
||||
{
|
||||
public int? RunTimeoutMs { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FormModel
|
||||
{
|
||||
public int? RunTimeoutMs { get; set; }
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -1,7 +1,6 @@
|
||||
@* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller
|
||||
creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via
|
||||
the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now
|
||||
(its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other
|
||||
the Configure-driver modal. Name is inline-validated as a RawPath segment. Markup mirrors the other
|
||||
/raw modal shells. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@@ -56,8 +55,8 @@
|
||||
/// <summary>Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after.</summary>
|
||||
[Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; }
|
||||
|
||||
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is
|
||||
// authorable now though its factory arrives in Wave C.
|
||||
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway". Guarded against
|
||||
// DriverTypeNames by RawDriverTypeDialogParityTests, which reads this field by reflection.
|
||||
private static readonly (string Label, string Value)[] Types =
|
||||
[
|
||||
("Modbus", DriverTypeNames.Modbus),
|
||||
|
||||
@@ -35,9 +35,15 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu
|
||||
/// <param name="LastError">Latest error message; null when none.</param>
|
||||
/// <param name="ErrorCount5Min">Faulted-transition count in the last 5 minutes.</param>
|
||||
/// <param name="PublishedUtc">Timestamp the snapshot was published.</param>
|
||||
/// <param name="RediscoveryNeededUtc">When the driver last reported that the remote's tag set may have
|
||||
/// changed; null when never (or when the driver cannot report it). Advisory — the served address space
|
||||
/// is unchanged and an operator must re-browse the device via <c>/raw</c> to pick anything up.</param>
|
||||
/// <param name="RediscoveryReason">The driver-supplied reason for that report; null when
|
||||
/// <paramref name="RediscoveryNeededUtc"/> is null.</param>
|
||||
public sealed record HostsDriverRow(
|
||||
string DriverInstanceId, string? Name, string? DriverType, string State,
|
||||
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc);
|
||||
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
|
||||
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// One cluster's section on the <c>/hosts</c> page: its configured nodes plus its enriched
|
||||
@@ -110,7 +116,9 @@ public static class HostsDriverView
|
||||
s.LastSuccessfulReadUtc,
|
||||
s.LastError,
|
||||
s.ErrorCount5Min,
|
||||
s.PublishedUtc);
|
||||
s.PublishedUtc,
|
||||
s.RediscoveryNeededUtc,
|
||||
s.RediscoveryReason);
|
||||
})
|
||||
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -3,6 +3,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
@@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper
|
||||
return new MTConnectTagConfigModel { FullName = address }.ToJson();
|
||||
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||
return WriteSingleKey("attributeRef", address);
|
||||
// Sql: a browsed leaf is a COLUMN, and a column name alone cannot address a tag — SqlTagConfigModel
|
||||
// needs the table too. SqlBrowseSession therefore travels schema/table/columnName in AddressFields.
|
||||
// Without this branch a Sql commit fell through to the generic "address" key below, which the typed
|
||||
// Sql editor does not read: it would open with empty fields and blank them on save — the exact
|
||||
// failure the MTConnect branch above was added to avoid (deferment.md G-6). Sql IS browsable
|
||||
// (SqlDriverBrowser), so the fallback's "browsable drivers are all handled above" was untrue.
|
||||
if (Is(driverType, DriverTypeNames.Sql))
|
||||
return BuildSqlTagConfig(address, addressFields);
|
||||
|
||||
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
|
||||
// "address" key so nothing is lost. Browsable drivers are all handled above.
|
||||
// "address" key so nothing is lost. Every BROWSABLE driver is handled above — guarded by
|
||||
// RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting
|
||||
// this comment.
|
||||
return WriteSingleKey("address", address);
|
||||
}
|
||||
|
||||
@@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper
|
||||
private static bool Is(string driverType, string name)
|
||||
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c>SqlTagConfigModel</c> blob from a browsed COLUMN leaf. Maps to
|
||||
/// <c>SqlTagModel.WideRow</c> — the browse tree is schema → table → column, which is precisely the
|
||||
/// wide-row shape (one row holds many signals as columns). A KeyValue (EAV) tag cannot be derived
|
||||
/// from a browse pick because its key VALUE is data, not schema, so the operator authors that in the
|
||||
/// typed editor.
|
||||
/// <para>Falls back to the generic key when the fields are absent — an older browser build, or a
|
||||
/// hand-made selection — rather than emitting a half-built Sql blob.</para>
|
||||
/// </summary>
|
||||
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
|
||||
{
|
||||
if (addressFields is null
|
||||
|| !addressFields.TryGetValue("table", out var table)
|
||||
|| string.IsNullOrWhiteSpace(table))
|
||||
{
|
||||
return WriteSingleKey("address", address);
|
||||
}
|
||||
|
||||
addressFields.TryGetValue("schema", out var schema);
|
||||
addressFields.TryGetValue("columnName", out var columnName);
|
||||
|
||||
// Qualify the table with its schema when the schema is not the default, so a tag authored against
|
||||
// "sales.Readings" cannot silently resolve to "dbo.Readings".
|
||||
var qualified = !string.IsNullOrWhiteSpace(schema) && !string.Equals(schema, "dbo", StringComparison.OrdinalIgnoreCase)
|
||||
? $"{schema}.{table}"
|
||||
: table;
|
||||
|
||||
return new SqlTagConfigModel
|
||||
{
|
||||
Model = SqlTagModel.WideRow,
|
||||
Table = qualified,
|
||||
ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName,
|
||||
}.ToJson();
|
||||
}
|
||||
|
||||
private static string WriteSingleKey(string key, string value)
|
||||
{
|
||||
var o = new JsonObject { [key] = value };
|
||||
|
||||
@@ -319,9 +319,11 @@ public static class CsvColumnMap
|
||||
public static IReadOnlyList<string> CommonColumns { get; } =
|
||||
[.. CommonLeadingColumns, TagConfigJsonColumn];
|
||||
|
||||
/// <summary>The Calculation (virtual-tag) driver-type string. Not yet a <see cref="DriverTypeNames"/>
|
||||
/// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface.</summary>
|
||||
public const string CalculationDriverType = "Calculation";
|
||||
/// <summary>The Calculation (virtual-tag) driver-type string.</summary>
|
||||
/// <remarks>Retained as an alias for <see cref="DriverTypeNames.Calculation"/>, which now exists — the
|
||||
/// original note here ("not yet a DriverTypeNames constant; its factory lands in a later Batch-2
|
||||
/// package") went stale once the factory registered.</remarks>
|
||||
public const string CalculationDriverType = DriverTypeNames.Calculation;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, CsvDriverTagMap> Maps = BuildMaps();
|
||||
|
||||
@@ -449,6 +451,46 @@ public static class CsvColumnMap
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int),
|
||||
}),
|
||||
|
||||
// deferment.md G-5 — Sql, Mqtt and MTConnect all have typed tag editors but had NO CSV map, so
|
||||
// import/export silently degraded them to the raw TagConfigJson fallback while the seven older
|
||||
// drivers got typed columns. Raw-key maps (as Galaxy and Calculation use) rather than
|
||||
// model-backed ones: they address the identity keys directly, which is what a CSV round-trip
|
||||
// needs, without duplicating each model's full accessor surface.
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.Sql, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("Model", "model", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("Table", "table", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("KeyColumn", "keyColumn", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("KeyValue", "keyValue", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("ValueColumn", "valueColumn", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("ColumnName", "columnName", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("SqlType", "type", null), CsvRawKeyKind.String),
|
||||
}),
|
||||
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.Mqtt, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("Mode", "mode", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("Topic", "topic", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("PayloadFormat", "payloadFormat", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("JsonPath", "jsonPath", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MqttDataType", "dataType", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("GroupId", "groupId", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("EdgeNodeId", "edgeNodeId", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MqttDeviceId", "deviceId", null), CsvRawKeyKind.String),
|
||||
}),
|
||||
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("DataItemId", "fullName", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtDataType", "dataType", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtCategory", "mtCategory", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtType", "mtType", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtSubType", "mtSubType", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtUnits", "units", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtDevice", "mtDevice", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtComponent", "mtComponent", null), CsvRawKeyKind.String),
|
||||
}),
|
||||
};
|
||||
|
||||
return maps.ToDictionary(m => m.DriverType, m => m, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -22,9 +22,9 @@ public static class TagConfigEditorMap
|
||||
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
||||
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared
|
||||
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
|
||||
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
||||
// Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
|
||||
// note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
|
||||
// the factory" — went stale when the constant landed; the key is correct either way.
|
||||
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
|
||||
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
|
||||
|
||||
@@ -118,7 +118,9 @@ public static class TelemetryProtoMapCentral
|
||||
LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(),
|
||||
LastError: msg.HasLastError ? msg.LastError : null,
|
||||
ErrorCount5Min: msg.ErrorCount5Min,
|
||||
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"));
|
||||
PublishedUtc: Required(msg.PublishedUtc, "DriverHealth", "published_utc"),
|
||||
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
|
||||
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null);
|
||||
}
|
||||
|
||||
/// <summary>Projects a <see cref="DriverResilienceStatus"/> onto a <see cref="DriverResilienceStatusChanged"/>.</summary>
|
||||
|
||||
@@ -128,6 +128,10 @@ public static class TelemetryProtoMapNode
|
||||
msg.LastSuccessfulReadUtc = ToUtcTimestamp(e.LastSuccessfulReadUtc.Value);
|
||||
if (e.LastError is not null)
|
||||
msg.LastError = e.LastError;
|
||||
if (e.RediscoveryNeededUtc is not null)
|
||||
msg.RediscoveryNeededUtc = ToUtcTimestamp(e.RediscoveryNeededUtc.Value);
|
||||
if (e.RediscoveryReason is not null)
|
||||
msg.RediscoveryReason = e.RediscoveryReason;
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -886,49 +886,6 @@ public sealed class AddressSpaceApplier
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise driver-discovered nodes (FixedTree) under an equipment at runtime. Idempotent:
|
||||
/// re-applies are cheap (the sink's EnsureFolder/EnsureVariable early-return on existing nodes), so
|
||||
/// this is safely re-run after every address-space rebuild. Folders are ensured parent-first.
|
||||
/// Emits a NodeAdded model-change so connected clients can refresh. Discovered nodes are read-only
|
||||
/// value nodes; array discovered nodes (rare) are forced read-only like the equipment-tag pass.
|
||||
/// </summary>
|
||||
/// <param name="equipmentRootNodeId">The equipment root node the discovered nodes hang under; the
|
||||
/// NodeAdded model-change is announced under this node.</param>
|
||||
/// <param name="folders">The discovered folders to ensure (parent-first by depth).</param>
|
||||
/// <param name="variables">The discovered variables to ensure (read-only value nodes).</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
|
||||
/// surfaces a non-zero count as a degraded discovered-node injection (archreview 01/S-1).</returns>
|
||||
public int MaterialiseDiscoveredNodes(
|
||||
string equipmentRootNodeId,
|
||||
IReadOnlyList<DiscoveredFolder> folders,
|
||||
IReadOnlyList<DiscoveredVariable> variables)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(equipmentRootNodeId);
|
||||
ArgumentNullException.ThrowIfNull(folders);
|
||||
ArgumentNullException.ThrowIfNull(variables);
|
||||
if (folders.Count == 0 && variables.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
|
||||
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
|
||||
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||
|
||||
foreach (var v in variables)
|
||||
{
|
||||
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
|
||||
var writable = v.Writable && !v.IsArray;
|
||||
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
|
||||
AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
|
||||
}
|
||||
|
||||
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
|
||||
equipmentRootNodeId, folders.Count, variables.Count, failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>A folder to ensure during discovered-node injection (NodeId + parent + display).</summary>
|
||||
public sealed record DiscoveredFolder(string NodeId, string? ParentNodeId, string DisplayName);
|
||||
|
||||
/// <summary>A read-or-write variable to ensure during discovered-node injection.</summary>
|
||||
public sealed record DiscoveredVariable(
|
||||
string NodeId, string ParentNodeId, string DisplayName, string DataType, bool Writable, bool IsArray, uint? ArrayLength);
|
||||
@@ -30,7 +30,13 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)
|
||||
public void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
DriverHealth health,
|
||||
int errorCount5Min,
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null)
|
||||
{
|
||||
var msg = new DriverHealthChanged(
|
||||
clusterId,
|
||||
@@ -39,7 +45,9 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
health.LastSuccessfulRead,
|
||||
health.LastError,
|
||||
errorCount5Min,
|
||||
DateTime.UtcNow);
|
||||
DateTime.UtcNow,
|
||||
rediscoveryNeededUtc,
|
||||
rediscoveryReason);
|
||||
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
|
||||
// Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC
|
||||
// client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap.
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IAddressSpaceBuilder"/> that RECORDS the streamed tree instead of creating OPC UA
|
||||
/// nodes — used to capture an <see cref="ITagDiscovery"/> driver's discovered hierarchy so the
|
||||
/// runtime can graft it under an equipment node. Folder nesting is tracked (each child builder
|
||||
/// carries its accumulated path), so every variable records its full <see cref="DiscoveredNode.FolderPathSegments"/>.
|
||||
/// <para>Value nodes only: <see cref="AddProperty"/> is ignored and alarm marking returns a no-op sink
|
||||
/// (discovered alarms are out of scope — alarms come via the config path).</para>
|
||||
/// <para>Single-threaded: a driver's <c>DiscoverAsync</c> streams on one caller; the root and its child
|
||||
/// builders share one <see cref="List{T}"/>. Not thread-safe by design.</para>
|
||||
/// </summary>
|
||||
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
private readonly List<DiscoveredNode> _nodes;
|
||||
private readonly IReadOnlyList<string> _path;
|
||||
|
||||
/// <summary>Create a root capturing builder with an empty folder path and a fresh node list.</summary>
|
||||
public CapturingAddressSpaceBuilder() : this([], []) { }
|
||||
|
||||
private CapturingAddressSpaceBuilder(List<DiscoveredNode> nodes, IReadOnlyList<string> path)
|
||||
{
|
||||
_nodes = nodes;
|
||||
_path = path;
|
||||
}
|
||||
|
||||
/// <summary>All variables captured across the whole tree (shared by the root and every child scope).</summary>
|
||||
public IReadOnlyList<DiscoveredNode> Nodes => _nodes;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
=> new CapturingAddressSpaceBuilder(_nodes, [.. _path, browseName]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
_nodes.Add(new DiscoveredNode(
|
||||
FolderPathSegments: _path,
|
||||
BrowseName: browseName,
|
||||
DisplayName: displayName,
|
||||
FullReference: attributeInfo.FullName,
|
||||
DataType: attributeInfo.DriverDataType,
|
||||
IsArray: attributeInfo.IsArray,
|
||||
ArrayDim: attributeInfo.ArrayDim,
|
||||
Writable: attributeInfo.SecurityClass != SecurityClassification.ViewOnly,
|
||||
IsHistorized: attributeInfo.IsHistorized));
|
||||
return new NullHandle(attributeInfo.FullName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { /* metadata only — ignored */ }
|
||||
|
||||
/// <summary>A variable handle whose alarm marking is a no-op (discovered alarms are out of scope).</summary>
|
||||
private sealed class NullHandle(string fullRef) : IVariableHandle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string FullReference => fullRef;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
/// <summary>A null sink that ignores alarm condition transitions.</summary>
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// A flattened variable captured from a driver's <see cref="ITagDiscovery.DiscoverAsync"/> stream
|
||||
/// by <see cref="CapturingAddressSpaceBuilder"/>. Folder nesting is preserved in
|
||||
/// <see cref="FolderPathSegments"/> so the injector can re-root the node under an equipment.
|
||||
/// </summary>
|
||||
public sealed record DiscoveredNode(
|
||||
IReadOnlyList<string> FolderPathSegments,
|
||||
string BrowseName,
|
||||
string DisplayName,
|
||||
string FullReference,
|
||||
DriverDataType DataType,
|
||||
bool IsArray,
|
||||
uint? ArrayDim,
|
||||
bool Writable,
|
||||
bool IsHistorized);
|
||||
@@ -1,118 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>The mapped result of grafting discovered nodes under an equipment node.</summary>
|
||||
/// <param name="Folders">
|
||||
/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT
|
||||
/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a
|
||||
/// global parent-before-child ordering across the whole list.
|
||||
/// </param>
|
||||
/// <param name="Variables">Variables to ensure under the (post-collapse) folders.</param>
|
||||
/// <param name="RoutingByRef">Driver FullReference -> equipment NodeId, for live-value routing.</param>
|
||||
public sealed record DiscoveredInjectionPlan(
|
||||
IReadOnlyList<DiscoveredFolder> Folders,
|
||||
IReadOnlyList<DiscoveredVariable> Variables,
|
||||
IReadOnlyDictionary<string, string> RoutingByRef); // driver FullReference -> equipment NodeId
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
|
||||
/// authored Config-DB refs and collapsing the single device-host folder. See the design doc
|
||||
/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md.
|
||||
/// </summary>
|
||||
public static class DiscoveredNodeMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps captured <paramref name="nodes"/> into folders + variables (NodeIds scoped under
|
||||
/// <paramref name="equipmentId"/>) plus a driver-FullReference → equipment-NodeId routing map.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The owning equipment's NodeId (root of the grafted subtree).</param>
|
||||
/// <param name="nodes">The captured discovery tree (from <c>CapturingAddressSpaceBuilder</c>).</param>
|
||||
/// <param name="authoredRefs">
|
||||
/// Driver FullReferences already authored as Config-DB equipment tags for this driver —
|
||||
/// skipped so a discovered node never shadows an authored one.
|
||||
/// </param>
|
||||
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
||||
public static DiscoveredInjectionPlan Map(
|
||||
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
||||
{
|
||||
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
|
||||
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
|
||||
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
|
||||
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
|
||||
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
||||
|
||||
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
|
||||
// device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads
|
||||
// FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the
|
||||
// level is retained so identical leaf names across devices don't collide (degrades gracefully).
|
||||
var collapseIndex1 = kept.Count > 0
|
||||
&& kept.All(n => n.FolderPathSegments.Count >= 2)
|
||||
&& kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
|
||||
|
||||
static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
|
||||
=> collapse ? [segs[0], .. segs.Skip(2)] : segs;
|
||||
|
||||
var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
|
||||
var variables = new List<DiscoveredVariable>();
|
||||
var routing = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var n in kept)
|
||||
{
|
||||
var segs = Effective(n.FolderPathSegments, collapseIndex1);
|
||||
|
||||
// Ensure every prefix folder, deduped, each parented at its prefix (the first segment's
|
||||
// parent is the equipment itself).
|
||||
for (var i = 0; i < segs.Count; i++)
|
||||
{
|
||||
var folderPath = string.Join('/', segs.Take(i + 1));
|
||||
var nodeId = Combine(rootNodeId, folderPath);
|
||||
if (folders.ContainsKey(nodeId)) continue;
|
||||
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
|
||||
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
||||
}
|
||||
|
||||
var varFolderPath = string.Join('/', segs);
|
||||
var varNodeId = string.IsNullOrEmpty(varFolderPath)
|
||||
? Combine(rootNodeId, n.BrowseName)
|
||||
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
|
||||
// A folder-less variable parents directly at the root device node.
|
||||
var varParent = string.IsNullOrEmpty(varFolderPath)
|
||||
? rootNodeId
|
||||
: Combine(rootNodeId, varFolderPath);
|
||||
variables.Add(new DiscoveredVariable(
|
||||
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
||||
routing[n.FullReference] = varNodeId;
|
||||
}
|
||||
|
||||
return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="DriverDataType"/> to the OPC-UA-built-in type STRING that
|
||||
/// <c>OtOpcUaNodeManager.EnsureVariable</c>'s <c>ResolveBuiltInDataType</c> accepts — so a
|
||||
/// discovered variable resolves to the same built-in type as an authored equipment tag. Most
|
||||
/// enum names pass through verbatim; <see cref="DriverDataType.Float32"/>/<see cref="DriverDataType.Float64"/>
|
||||
/// map to the SDK's "Float"/"Double" names, and <see cref="DriverDataType.Reference"/> (a Galaxy
|
||||
/// attribute reference) is carried as an OPC UA String per the enum's own contract.
|
||||
/// </summary>
|
||||
private static string ToBuiltinTypeString(DriverDataType dt) => dt switch
|
||||
{
|
||||
DriverDataType.Boolean => "Boolean",
|
||||
DriverDataType.Int16 => "Int16",
|
||||
DriverDataType.Int32 => "Int32",
|
||||
DriverDataType.Int64 => "Int64",
|
||||
DriverDataType.UInt16 => "UInt16",
|
||||
DriverDataType.UInt32 => "UInt32",
|
||||
DriverDataType.UInt64 => "UInt64",
|
||||
DriverDataType.Float32 => "Float",
|
||||
DriverDataType.Float64 => "Double",
|
||||
DriverDataType.String => "String",
|
||||
DriverDataType.DateTime => "DateTime",
|
||||
DriverDataType.Reference => "String",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."),
|
||||
};
|
||||
}
|
||||
@@ -244,37 +244,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// value maps so stale condition state never leaks across redeploys.</summary>
|
||||
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
|
||||
|
||||
/// <summary>In-flight <see cref="DriverInstanceActor.ApplyDelta"/> sends, keyed by correlation, so
|
||||
/// <see cref="HandleApplyResult"/> can advance the cached spec only once the child confirms it adopted
|
||||
/// the config (#516). Bounded by the number of driver children with a delta in flight.</summary>
|
||||
private readonly Dictionary<CorrelationId, DriverInstanceSpec> _pendingDelta = new();
|
||||
|
||||
/// <summary>The composition from the most-recent apply (set at the END of
|
||||
/// <see cref="PushDesiredSubscriptions"/>). Discovered-node injection
|
||||
/// (<see cref="HandleDiscoveredNodes"/>) reads it to resolve the equipment bound to a driver (from the
|
||||
/// composition's <c>EquipmentNodes</c> whose <c>DriverInstanceId</c> matches, UNION the authored
|
||||
/// <c>EquipmentTags</c> for that driver — so a driver with zero authored tags can still graft onto an
|
||||
/// equipment bound via <c>EquipmentNode.DriverInstanceId</c>) and to recompute the authored value + alarm
|
||||
/// subscription sets when merging FixedTree refs. Null until the first apply — a
|
||||
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> arriving before any apply is ignored.</summary>
|
||||
/// <see cref="PushDesiredSubscriptions"/>). Null until the first apply.</summary>
|
||||
private AddressSpaceComposition? _lastComposition;
|
||||
|
||||
/// <summary>The most-recent discovered-injection plan(s) per driver instance, cached so the redeploy
|
||||
/// re-inject tail can re-apply the live graft after an address-space rebuild without re-running discovery.
|
||||
/// Keyed by DriverInstanceId at the OUTER level, then by EquipmentId at the INNER level (driver → (equipment
|
||||
/// → plan)). Today only the single-equipment case is populated, so the inner map always has exactly one
|
||||
/// entry; the inner map is shaped per-equipment now so the follow-up multi-device-partition task can hold
|
||||
/// multiple (equipmentId → plan) entries per driver without reshaping this cache. Inner dict is mutable
|
||||
/// (the redeploy tail drops stale per-equipment entries in place); both levels are Ordinal-keyed.
|
||||
/// Last-writer-wins on a re-discovery (the whole inner map is replaced).</summary>
|
||||
private readonly Dictionary<string, Dictionary<string, DiscoveredInjectionPlan>> _discoveredByDriver =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Per-driver signature of the last-logged device-host PARTITION diagnostic (unmatched / ambiguous
|
||||
/// / degenerate host), folded with the current revision, so the ~15 repeated re-discovery passes within a
|
||||
/// connect don't re-warn an unchanged condition: it is WARNED once when it first appears (or changes), and
|
||||
/// DEBUG-logged on the identical repeat passes. Folding in <see cref="_currentRevision"/> makes a redeploy
|
||||
/// re-warn once. Best-effort LOG-LEVEL dedup ONLY — never affects grafting; the matched-plan re-apply is
|
||||
/// separately short-circuited by <see cref="PlansRoutingEqual"/>. Cleared for a driver whose partition comes
|
||||
/// back clean so a later recurrence re-warns; bounded by driver count (a few). Only touched on the
|
||||
/// multi-candidate path (<see cref="PartitionDiscoveredByDeviceHost"/>).</summary>
|
||||
private readonly Dictionary<string, string> _lastPartitionWarnSignature = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
||||
/// snapshot (null = unknown until the first snapshot arrives, or no local node match). The Primary
|
||||
@@ -894,8 +872,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||
@@ -928,8 +906,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||
@@ -973,349 +951,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a driver child's post-connect <see cref="DriverInstanceActor.DiscoveredNodesReady"/>:
|
||||
/// resolves the equipment the driver is bound to from the most-recent applied composition (its
|
||||
/// <c>EquipmentNodes</c> bound by <c>DriverInstanceId</c> UNION its authored <c>EquipmentTags</c>),
|
||||
/// maps the captured FixedTree under it via <see cref="DiscoveredNodeMapper"/> (deduping any node that
|
||||
/// shadows an authored equipment-tag ref), caches the per-equipment plan map, and grafts it onto the
|
||||
/// served address space + live-value maps + subscription set via
|
||||
/// <see cref="ApplyDiscoveredPlansForDriver"/>. Idempotent / duplicate-safe: the mapper is pure,
|
||||
/// materialisation is idempotent, and the routing-map extension + subscription merge are set-based.
|
||||
/// </summary>
|
||||
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
|
||||
{
|
||||
// v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
|
||||
// driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
|
||||
// retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
|
||||
// authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
|
||||
// downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
|
||||
// equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
|
||||
// caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
|
||||
// too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
|
||||
// is a separate follow-up.
|
||||
_log.Debug(
|
||||
"DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
|
||||
_localNode, msg.DriverInstanceId);
|
||||
return;
|
||||
|
||||
#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
|
||||
if (_lastComposition is null)
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
|
||||
_localNode, msg.DriverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the equipment bound to this driver from BOTH the composition's EquipmentNodes (whose
|
||||
// DriverInstanceId matches — this lets a driver with ZERO authored tags graft onto a tag-less
|
||||
// equipment) UNION the authored EquipmentTags for the driver (the original resolution). Distinct so a
|
||||
// driver that is both EquipmentNode-bound AND has authored tags under the same equipment resolves once.
|
||||
var fromNodes = _lastComposition.EquipmentNodes
|
||||
.Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
|
||||
.Select(e => e.EquipmentId);
|
||||
var fromTags = _lastComposition.EquipmentTags
|
||||
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
|
||||
.Select(t => t.EquipmentId);
|
||||
var equipmentIds = fromNodes.Concat(fromTags).Distinct(StringComparer.Ordinal).ToList();
|
||||
if (equipmentIds.Count == 0)
|
||||
{
|
||||
_log.Info("DriverHost {Node}: no equipment for driver {Driver} — skipping discovered-node injection",
|
||||
_localNode, msg.DriverInstanceId);
|
||||
return;
|
||||
}
|
||||
// Authored refs for THIS driver (DRIVER-WIDE — both value + alarm tags) so a discovered node never
|
||||
// shadows an authored one — the mapper drops any captured node whose FullReference is already authored.
|
||||
// May be EMPTY for a tag-less equipment, which is fine: Map dedups against an empty set (keeps
|
||||
// everything). Safe even for the multi-device partition below: a FOCAS FullReference is host-prefixed,
|
||||
// so a device-X discovered node can't collide with a device-Y authored ref — the driver-wide set is
|
||||
// correct per partition.
|
||||
var authoredRefs = _lastComposition.EquipmentTags
|
||||
.Where(t => string.Equals(t.DriverInstanceId, msg.DriverInstanceId, StringComparison.Ordinal))
|
||||
.Select(t => t.FullName)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Build this discovery's per-equipment plan map.
|
||||
// • EXACTLY ONE candidate ⇒ map the WHOLE captured tree under it (the mapper collapses the single
|
||||
// device-host folder ⇒ clean EQ-n/FOCAS/...). Unchanged from before.
|
||||
// • MORE THAN ONE candidate ⇒ PARTITION the captured tree by its (normalized) device-host folder
|
||||
// segment and graft each device's subset under the equipment whose DeviceHost matches (follow-up E
|
||||
// part 2). Unmatched/ambiguous hosts are warn-skipped (safe), not mis-grafted; a degenerate case
|
||||
// (>1 candidate, none has a DeviceHost) warn-skips the whole driver. See PartitionDiscoveredByDeviceHost.
|
||||
Dictionary<string, DiscoveredInjectionPlan> newPlans;
|
||||
if (equipmentIds.Count == 1)
|
||||
{
|
||||
var plan = DiscoveredNodeMapper.Map(equipmentIds[0], msg.Nodes, authoredRefs);
|
||||
if (plan.Variables.Count == 0) return; // nothing new to inject (all captured nodes were authored)
|
||||
newPlans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal) { [equipmentIds[0]] = plan };
|
||||
}
|
||||
else
|
||||
{
|
||||
newPlans = PartitionDiscoveredByDeviceHost(msg, equipmentIds, authoredRefs);
|
||||
if (newPlans.Count == 0) return; // degenerate / no host matched a graftable partition — already logged
|
||||
}
|
||||
|
||||
// Unchanged-plan short-circuit (shared by the single- AND multi-device paths): the driver re-discovers
|
||||
// every ~2s (up to ~15 passes) until the FixedTree set stabilises, re-sending DiscoveredNodesReady each
|
||||
// pass. Re-applying an IDENTICAL set would re-send SetDesiredSubscriptions, forcing the child to
|
||||
// UnsubscribeAsync (dropping the WHOLE handle — authored tags included) then re-Subscribe — blipping
|
||||
// authored-tag values up to ~15× across the discovery window. Skip when the WHOLE per-equipment routing
|
||||
// is unchanged from the last applied pass; a GROWING set still differs (superset) and re-applies. (This
|
||||
// is also why an unmatched/ambiguous partition warning settles: once the matched partitions stabilise we
|
||||
// short-circuit here, and the partition warns are themselves signature-deduped — see ShouldWarnPartition.)
|
||||
if (_discoveredByDriver.TryGetValue(msg.DriverInstanceId, out var cached)
|
||||
&& PlansRoutingEqual(cached, newPlans))
|
||||
{
|
||||
var total = newPlans.Values.Sum(p => p.Variables.Count);
|
||||
_log.Debug("DriverHost {Node}: discovered set for driver {Driver} unchanged ({Count} node(s) across {Equipment} equipment(s)) — re-apply skipped",
|
||||
_localNode, msg.DriverInstanceId, total, newPlans.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
|
||||
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
|
||||
#pragma warning restore CS0162
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment
|
||||
/// (<c>FolderPathSegments[1]</c>) and maps each device's subset under the candidate equipment whose
|
||||
/// <see cref="EquipmentNode.DeviceHost"/> matches — the multi-device graft. Returns
|
||||
/// the per-equipment plan map (one entry per device that matched AND had at least one new variable);
|
||||
/// EMPTY when nothing is graftable.
|
||||
/// <list type="bullet">
|
||||
/// <item>Builds <c>normalizedHost → equipmentId</c> from the candidate <see cref="EquipmentNode"/>s
|
||||
/// that carry a non-null DeviceHost. Two distinct candidates sharing a host is AMBIGUOUS — that host
|
||||
/// is un-mapped (its nodes are warn-skipped) rather than grafted onto an arbitrary equipment.</item>
|
||||
/// <item><b>I1 divergence:</b> a candidate WITHOUT a DeviceHost (e.g. resolved via authored tags only,
|
||||
/// no device binding) simply gets no partition — the FixedTree is the device's structure, so it
|
||||
/// belongs under the device-bound equipment. No crash; that candidate is just not a partition target.</item>
|
||||
/// <item>If NO candidate has a DeviceHost at all there is nothing to partition on ⇒ DEGENERATE ⇒
|
||||
/// warn-skip the whole driver (returns empty).</item>
|
||||
/// <item>A discovered partition whose host is unmatched (or whose node has <2 folder segments, so no
|
||||
/// host folder) is warn-skipped — its nodes are NOT mis-grafted; the matched partitions still graft.</item>
|
||||
/// </list>
|
||||
/// The device-host folder segment AND the stored DeviceHost are both run through the SAME
|
||||
/// <see cref="DeviceConfigIntent.NormalizeHost"/> (single source of truth), so they compare equal
|
||||
/// regardless of case/whitespace.
|
||||
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
|
||||
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
|
||||
/// <para><b>Mid-connect partition shrink.</b> If a later pass yields FEWER device partitions than a
|
||||
/// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT
|
||||
/// actively pruned until the next full redeploy (<see cref="PushDesiredSubscriptions"/> Clears + rebuilds
|
||||
/// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption —
|
||||
/// no mid-connect pruning is built here (out of scope).</para>
|
||||
/// </summary>
|
||||
private Dictionary<string, DiscoveredInjectionPlan> PartitionDiscoveredByDeviceHost(
|
||||
DriverInstanceActor.DiscoveredNodesReady msg,
|
||||
IReadOnlyList<string> equipmentIds,
|
||||
IReadOnlySet<string> authoredRefs)
|
||||
{
|
||||
var driverId = msg.DriverInstanceId;
|
||||
var candidateSet = equipmentIds.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// normalizedHost → equipmentId, from candidate EquipmentNodes that carry a DeviceHost. A host shared by
|
||||
// two DISTINCT candidates is ambiguous: un-map it (warn-skip) so its nodes aren't grafted arbitrarily.
|
||||
var hostToEquipment = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var ambiguousHosts = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var node in _lastComposition!.EquipmentNodes)
|
||||
{
|
||||
if (!candidateSet.Contains(node.EquipmentId) || node.DeviceHost is null) continue;
|
||||
// DeviceHost is already normalized at compose/decode time; re-normalize through the shared helper so
|
||||
// the comparison is the single source of truth (idempotent — harmless if it was already normalized).
|
||||
var host = DeviceConfigIntent.NormalizeHost(node.DeviceHost);
|
||||
if (ambiguousHosts.Contains(host)) continue;
|
||||
if (hostToEquipment.TryGetValue(host, out var existing))
|
||||
{
|
||||
if (!string.Equals(existing, node.EquipmentId, StringComparison.Ordinal))
|
||||
{
|
||||
hostToEquipment.Remove(host);
|
||||
ambiguousHosts.Add(host);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
hostToEquipment[host] = node.EquipmentId;
|
||||
}
|
||||
|
||||
// DEGENERATE: >1 candidate but none resolved a DeviceHost ⇒ nothing to partition on ⇒ warn-skip the
|
||||
// whole driver. (Falls through the same warn-once dedup as the unmatched case.)
|
||||
if (hostToEquipment.Count == 0 && ambiguousHosts.Count == 0)
|
||||
{
|
||||
if (ShouldWarnPartition(driverId, "degenerate"))
|
||||
_log.Warning("DriverHost {Node}: driver {Driver} maps to {Count} equipments but none has a DeviceHost — discovered-node injection skipped (no device-host to partition on)",
|
||||
_localNode, driverId, equipmentIds.Count);
|
||||
else
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} still has no DeviceHost on any of {Count} equipments — skipped (repeat)",
|
||||
_localNode, driverId, equipmentIds.Count);
|
||||
return new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// Partition the captured tree by its device-host folder segment (FolderPathSegments[1]); a node with
|
||||
// <2 segments has no host folder (null ⇒ unmatched). Keep only nodes whose host matches a candidate.
|
||||
var matchedNodes = new Dictionary<string, List<DiscoveredNode>>(StringComparer.Ordinal);
|
||||
var unmatchedHosts = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var n in msg.Nodes)
|
||||
{
|
||||
var key = n.FolderPathSegments.Count >= 2
|
||||
? DeviceConfigIntent.NormalizeHost(n.FolderPathSegments[1])
|
||||
: null;
|
||||
if (key is not null && hostToEquipment.ContainsKey(key))
|
||||
{
|
||||
if (!matchedNodes.TryGetValue(key, out var list))
|
||||
matchedNodes[key] = list = new List<DiscoveredNode>();
|
||||
list.Add(n);
|
||||
}
|
||||
else
|
||||
{
|
||||
unmatchedHosts.Add(key ?? "(no-device-host-folder)");
|
||||
}
|
||||
}
|
||||
|
||||
// Map each matched device's subset under its equipment. ONE device per partition ⇒ the mapper collapses
|
||||
// that partition's single host folder ⇒ clean EQ-n/FOCAS/...; a plan with zero new variables (all
|
||||
// shadowed by authored refs) contributes no entry.
|
||||
// NOTE: DiscoveredNodeMapper.Map's collapse predicate compares the host segment with RAW
|
||||
// StringComparer.Ordinal, whereas we grouped on the NORMALIZED host. Harmless: a real FOCAS device
|
||||
// emits one consistent HostAddress string per device, so a partition is single-host either way (collapse
|
||||
// fires). Even if two raw spellings of the same host slipped into one partition, the only effect would be
|
||||
// a retained (non-collapsed) host folder — never a mis-graft or NodeId collision (the equipment scope
|
||||
// already isolates them).
|
||||
var plans = new Dictionary<string, DiscoveredInjectionPlan>(StringComparer.Ordinal);
|
||||
foreach (var (host, nodes) in matchedNodes)
|
||||
{
|
||||
var equipmentId = hostToEquipment[host];
|
||||
var plan = DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs);
|
||||
if (plan.Variables.Count > 0) plans[equipmentId] = plan;
|
||||
}
|
||||
|
||||
// Surface unmatched/ambiguous hosts ONCE (then Debug on the repeated passes). The matched partitions
|
||||
// above still graft regardless. When the partition came back fully clean, drop the driver's signature so
|
||||
// a later recurrence re-warns.
|
||||
if (unmatchedHosts.Count > 0 || ambiguousHosts.Count > 0)
|
||||
{
|
||||
var unmatched = string.Join(",", unmatchedHosts.OrderBy(h => h, StringComparer.Ordinal));
|
||||
var ambiguous = string.Join(",", ambiguousHosts.OrderBy(h => h, StringComparer.Ordinal));
|
||||
if (ShouldWarnPartition(driverId, "u:" + unmatched + "|a:" + ambiguous))
|
||||
_log.Warning("DriverHost {Node}: driver {Driver}: discovered device-host partition(s) skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}]; matched partitions still grafted",
|
||||
_localNode, driverId, unmatched, ambiguous);
|
||||
else
|
||||
_log.Debug("DriverHost {Node}: driver {Driver}: device-host partition(s) still skipped — unmatched=[{Unmatched}] ambiguous=[{Ambiguous}] (repeat)",
|
||||
_localNode, driverId, unmatched, ambiguous);
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastPartitionWarnSignature.Remove(driverId);
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
/// <summary>Best-effort LOG-LEVEL dedup for the device-host partition diagnostics: returns true (⇒ WARN)
|
||||
/// when <paramref name="conditionKey"/> is newly-seen for the driver this revision, false (⇒ DEBUG) on the
|
||||
/// identical repeat passes that the ~15×/connect re-discovery produces. Folds the current revision in so a
|
||||
/// redeploy re-warns once. Records the signature as a side effect. Never affects grafting behavior — only
|
||||
/// the log level — so a stale entry (e.g. after a transient single↔multi candidate flip) at worst demotes
|
||||
/// one duplicate warn to Debug.</summary>
|
||||
private bool ShouldWarnPartition(string driverId, string conditionKey)
|
||||
{
|
||||
var signature = (_currentRevision?.ToString() ?? "none") + "|" + conditionKey;
|
||||
var isNew = !_lastPartitionWarnSignature.TryGetValue(driverId, out var prev)
|
||||
|| !string.Equals(prev, signature, StringComparison.Ordinal);
|
||||
_lastPartitionWarnSignature[driverId] = signature;
|
||||
return isNew;
|
||||
}
|
||||
|
||||
/// <summary>Routing-map equality: same count + every key maps to the same NodeId. Lets
|
||||
/// <see cref="HandleDiscoveredNodes"/> skip re-applying an unchanged discovered set across the driver's
|
||||
/// repeated post-connect re-discovery passes (a grown/changed set differs and re-applies).</summary>
|
||||
private static bool RoutingEquals(IReadOnlyDictionary<string, string> a, IReadOnlyDictionary<string, string> b)
|
||||
=> a.Count == b.Count
|
||||
&& a.All(kv => b.TryGetValue(kv.Key, out var v) && string.Equals(v, kv.Value, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Per-equipment plan-map routing equality: same equipment keys + each equipment's plan has the
|
||||
/// same <see cref="DiscoveredInjectionPlan.RoutingByRef"/> (via <see cref="RoutingEquals"/>). Lets
|
||||
/// <see cref="HandleDiscoveredNodes"/> short-circuit a re-discovery whose WHOLE per-driver set is unchanged
|
||||
/// (a grown/changed set on any equipment differs and re-applies).</summary>
|
||||
private static bool PlansRoutingEqual(
|
||||
IReadOnlyDictionary<string, DiscoveredInjectionPlan> a,
|
||||
IReadOnlyDictionary<string, DiscoveredInjectionPlan> b)
|
||||
=> a.Count == b.Count
|
||||
&& a.All(kv => b.TryGetValue(kv.Key, out var p) && RoutingEquals(kv.Value.RoutingByRef, p.RoutingByRef));
|
||||
|
||||
/// <summary>
|
||||
/// Grafts a driver's per-equipment <see cref="DiscoveredInjectionPlan"/> map onto the served state in
|
||||
/// two phases so the resubscribe stays a single push per driver (the shape the multi-device-partition
|
||||
/// follow-up needs without resubscribe churn):
|
||||
/// <list type="number">
|
||||
/// <item><b>Materialise per equipment</b> — for each <c>(equipmentId, plan)</c> entry, extend the
|
||||
/// live-value routing map (mirroring <see cref="PushDesiredSubscriptions"/>' fan-out so
|
||||
/// <see cref="ForwardToMux"/> lands FixedTree values on the right node) and Tell the publish actor
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for
|
||||
/// that equipment (idempotent).</item>
|
||||
/// <item><b>Subscribe ONCE per driver</b> — compute the union of the driver's authored value refs
|
||||
/// (recomputed the same way <see cref="PushDesiredSubscriptions"/> does) and the FixedTree refs of
|
||||
/// ALL the driver's cached plans, then Tell the child a single
|
||||
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> so the poll engine reads them and the
|
||||
/// values flow. For a single-equipment driver this equals the prior per-plan behavior.</item>
|
||||
/// </list>
|
||||
/// Extracted as a standalone method so the redeploy re-inject tail can re-apply the cached plans after
|
||||
/// an address-space rebuild without re-running discovery.
|
||||
/// </summary>
|
||||
private void ApplyDiscoveredPlansForDriver(
|
||||
string driverId, IReadOnlyDictionary<string, DiscoveredInjectionPlan> plansByEquipment)
|
||||
{
|
||||
// (a) Per-equipment: extend the live-value routing map (fan-out, mirroring PushDesiredSubscriptions'
|
||||
// pattern) + materialise the discovered folders + variables under that equipment (idempotent). This is
|
||||
// purely ADDITIVE across passes: a shrinking discovery set would leave the dropped refs' stale routes
|
||||
// until the next full apply (PushDesiredSubscriptions) clears + rebuilds the maps — acceptable because
|
||||
// a FOCAS FixedTree only grows-then-stabilises, never shrinks within a connect.
|
||||
var totalVariables = 0;
|
||||
foreach (var (equipmentId, plan) in plansByEquipment)
|
||||
{
|
||||
foreach (var (driverRef, nodeId) in plan.RoutingByRef)
|
||||
{
|
||||
var key = (driverId, driverRef);
|
||||
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
|
||||
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
|
||||
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
|
||||
// through the Raw realm.
|
||||
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
|
||||
_driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
|
||||
}
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
|
||||
equipmentId, plan.Folders, plan.Variables));
|
||||
totalVariables += plan.Variables.Count;
|
||||
}
|
||||
|
||||
// (b) ONE subscription push per driver: merge the FixedTree refs from ALL the driver's plans into the
|
||||
// driver's desired subscription set so the poll engine reads them and ForwardToMux routes the values.
|
||||
// Recompute the authored value + alarm refs the same way PushDesiredSubscriptions does, then union the
|
||||
// FixedTree refs onto the value set. Doing the union here (rather than once per plan) means the
|
||||
// multi-device task adds inner-map entries without changing this single-send shape.
|
||||
if (!_children.TryGetValue(driverId, out var entry)) return;
|
||||
// The _lastComposition null-guards below are defensive: HandleDiscoveredNodes already proved it
|
||||
// non-null, but the redeploy tail also calls this from the PushDesiredSubscriptions tail — keep them
|
||||
// so that re-apply path can't NRE.
|
||||
var authoredValueRefs = _lastComposition is null
|
||||
? Enumerable.Empty<string>()
|
||||
: _lastComposition.EquipmentTags
|
||||
.Where(t => t.Alarm is null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
|
||||
.Select(t => t.FullName);
|
||||
var alarmRefs = _lastComposition is null
|
||||
? Array.Empty<string>()
|
||||
: _lastComposition.EquipmentTags
|
||||
.Where(t => t.Alarm is not null && string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
|
||||
.Select(t => t.FullName)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var discoveredRefs = plansByEquipment.Values.SelectMany(p => p.RoutingByRef.Keys);
|
||||
var union = authoredValueRefs.Concat(discoveredRefs).Distinct(StringComparer.Ordinal).ToArray();
|
||||
entry.Actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(union, SubscriptionPublishingInterval, alarmRefs));
|
||||
|
||||
_log.Info("DriverHost {Node}: injected {Count} discovered node(s) for driver {Driver} across {Equipment} equipment(s)",
|
||||
_localNode, totalVariables, driverId, plansByEquipment.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a native alarm transition (published by a driver child as
|
||||
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
|
||||
@@ -1719,16 +1354,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<RouteNativeAlarmAck>(msg =>
|
||||
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
|
||||
_localNode, msg.ConditionNodeId));
|
||||
// A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is
|
||||
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
|
||||
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
|
||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
|
||||
// A child connectivity transition while the host is Stale has no live address space to annotate — drop
|
||||
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
|
||||
}
|
||||
@@ -2316,15 +1948,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
|
||||
// is still untouched (the re-inject tail below drops/removes entries). Cached drivers are SKIPPED in the
|
||||
// bulk loop because the tail sends each of them EXACTLY ONE SetDesiredSubscriptions for this pass: the
|
||||
// authored∪discovered union (ApplyDiscoveredPlansForDriver) for a survivor, or — if its plan is fully
|
||||
// dropped — an authored-only fallback. Sending the bulk authored-only set HERE too would force the child
|
||||
// to drop the whole handle (authored tags included) then re-subscribe — an extra unsub/resub blip of the
|
||||
// authored values once per cached driver per redeploy. Net effect: exactly ONE send per driver per pass.
|
||||
var cachedDriverIds = _discoveredByDriver.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// One authored-only push (value refs + alarm refs from the maps built above), shared by the bulk loop AND
|
||||
// the dropped-driver fallback so the two CANNOT drift: the fallback's correctness depends on sending the
|
||||
// SAME payload the bulk loop would have, so it's a shared helper (structural), not a comment-maintained
|
||||
@@ -2341,9 +1964,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
var total = 0;
|
||||
foreach (var (driverId, entry) in _children)
|
||||
{
|
||||
// Cached drivers are owned exclusively by the re-inject tail (one send each) — skip here. Non-cached
|
||||
// drivers keep the bulk authored-only send exactly as before.
|
||||
if (cachedDriverIds.Contains(driverId)) continue;
|
||||
total += SendAuthoredOnly(entry.Actor, driverId);
|
||||
}
|
||||
|
||||
@@ -2380,94 +2000,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
_localNode, composition.EquipmentScriptedAlarms.Count);
|
||||
}
|
||||
|
||||
// Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve
|
||||
// the equipment bound to a driver + recompute the authored subscription sets when a driver later
|
||||
// reports its FixedTree. Set here (not in ApplyAndAck) so both the fresh-apply and bootstrap-restore
|
||||
// paths — which both route through this method — leave a current composition.
|
||||
// Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and
|
||||
// bootstrap-restore paths — which both route through this method — leave a current composition.
|
||||
_lastComposition = composition;
|
||||
|
||||
// Re-inject discovered (FixedTree) nodes after the authored rebuild. PushDesiredSubscriptions cleared
|
||||
// _nodeIdByDriverRef and re-pushed authored-only subscriptions above; without this, an IN-PROCESS
|
||||
// redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
|
||||
// would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
|
||||
// and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
|
||||
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from the
|
||||
// post-connect re-discovery loop, NOT this re-apply. Re-resolve each cached driver's candidate equipments
|
||||
// from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes
|
||||
// uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its
|
||||
// equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to
|
||||
// that equipmentId (a rebind). A driver whose inner map empties out is removed entirely. The surviving
|
||||
// entries are re-applied via the single-send-per-driver structure. (The single-equipment case today has
|
||||
// exactly one inner entry; the multi-device task adds more.)
|
||||
foreach (var driverId in _discoveredByDriver.Keys.ToList()) // snapshot — we mutate the dict below
|
||||
{
|
||||
var fromNodes = composition.EquipmentNodes
|
||||
.Where(e => e.DriverInstanceId is not null && string.Equals(e.DriverInstanceId, driverId, StringComparison.Ordinal))
|
||||
.Select(e => e.EquipmentId);
|
||||
var fromTags = composition.EquipmentTags
|
||||
.Where(t => string.Equals(t.DriverInstanceId, driverId, StringComparison.Ordinal))
|
||||
.Select(t => t.EquipmentId);
|
||||
var candidates = fromNodes.Concat(fromTags).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var plansByEquipment = _discoveredByDriver[driverId];
|
||||
// Track whether ANY entry was dropped (no-longer-candidate or rebind) so we can re-trigger this
|
||||
// driver's discovery exactly ONCE after the inner map is processed (see the post-loop block).
|
||||
var droppedAny = false;
|
||||
foreach (var equipmentId in plansByEquipment.Keys.ToList()) // snapshot — we mutate the inner dict
|
||||
{
|
||||
var plan = plansByEquipment[equipmentId];
|
||||
if (!candidates.Contains(equipmentId))
|
||||
{
|
||||
plansByEquipment.Remove(equipmentId);
|
||||
droppedAny = true;
|
||||
_log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment no longer resolves", _localNode, driverId, equipmentId);
|
||||
continue;
|
||||
}
|
||||
// If the equipment was rebound (the cached plan's NodeIds are scoped to the OLD equipment), drop +
|
||||
// let re-discovery rebuild against the new equipment. The plan's NodeIds are "{equipmentId}/...".
|
||||
var planEquipmentConsistent = plan.Variables.Count > 0
|
||||
&& plan.Variables[0].NodeId.StartsWith(equipmentId + "/", StringComparison.Ordinal);
|
||||
if (!planEquipmentConsistent)
|
||||
{
|
||||
plansByEquipment.Remove(equipmentId);
|
||||
droppedAny = true;
|
||||
_log.Debug("DriverHost {Node}: dropped cached discovered nodes for {Driver}/{Equipment} — equipment rebound", _localNode, driverId, equipmentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-trigger discovery when ANY entry was dropped (no-longer-candidate or rebind). A CONFIG-UNCHANGED
|
||||
// rebind (the driver's DriverConfig is identical, only its authored tag's EquipmentId moved) is NOT
|
||||
// restarted by ReconcileDrivers — the child stays Connected — so without this nudge the FixedTree
|
||||
// subtree would stay ABSENT under the new equipment until the driver's next natural reconnect. We now
|
||||
// ask the child to re-run discovery so it re-grafts promptly: the next pass resolves against the new
|
||||
// _lastComposition (the now-bound equipment). This is a DISCOVERY action, not lifecycle control — no
|
||||
// stop/restart; it is idempotent, and the child no-ops it if not Connected (handled in
|
||||
// DriverInstanceActor). Sent at most ONCE per driver per re-inject pass (here, after the inner map is
|
||||
// processed — so even when the inner map empties below), guarded on the child still existing.
|
||||
if (droppedAny && _children.TryGetValue(driverId, out var rediscoverEntry))
|
||||
rediscoverEntry.Actor.Tell(new DriverInstanceActor.TriggerRediscovery());
|
||||
|
||||
if (plansByEquipment.Count == 0)
|
||||
{
|
||||
_discoveredByDriver.Remove(driverId);
|
||||
// Drop the driver's partition warn-signature too so a permanently-removed/rebound driver doesn't
|
||||
// leak a stale entry (log-level-only state; bounded by driver count — just tidiness).
|
||||
_lastPartitionWarnSignature.Remove(driverId);
|
||||
// FALLBACK (one-send invariant): this driver was SKIPPED in the bulk loop (it was cached), and its
|
||||
// plan is now FULLY DROPPED — so ApplyDiscoveredPlansForDriver won't run for it and it would
|
||||
// otherwise receive ZERO sends this pass, losing its AUTHORED subscriptions. Send the authored-only
|
||||
// set NOW (the SAME payload the bulk loop computes), so the authored tags subscribe in THIS pass.
|
||||
// (The TriggerRediscovery above handles the async FixedTree re-graft separately; this just keeps
|
||||
// the authored values live meanwhile.) Guarded on the child still existing — a driver removed by
|
||||
// ReconcileDrivers has no child and correctly gets no send. Shares SendAuthoredOnly with the bulk
|
||||
// loop so the payload can't drift; a ZERO-authored driver sends an empty set → Unsubscribe (drops
|
||||
// the stale FixedTree handle without a spurious subscribe).
|
||||
if (_children.TryGetValue(driverId, out var fallbackEntry))
|
||||
SendAuthoredOnly(fallbackEntry.Actor, driverId);
|
||||
continue;
|
||||
}
|
||||
ApplyDiscoveredPlansForDriver(driverId, plansByEquipment);
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnChild(DriverInstanceSpec spec)
|
||||
@@ -2479,7 +2015,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverHost {Node}: factory for {Type} threw on {Id}; stubbing",
|
||||
// A factory throw is a CONFIG error, not a connectivity one — TryCreate is pure parsing;
|
||||
// device I/O happens later in InitializeAsync. Logged at Error because the node silently
|
||||
// degrades to a stub that answers nothing, and since #516 routed DriverConfig changes
|
||||
// through a respawn this path is now reachable by an ordinary operator edit rather than
|
||||
// only by a brand-new driver. It still does NOT fail the deployment — making it do so is
|
||||
// a deliberate follow-up, since it would let one malformed driver block a fleet deploy.
|
||||
_log.Error(ex,
|
||||
"DriverHost {Node}: factory for {Type} REJECTED the config for {Id} — the driver is " +
|
||||
"stubbed and will serve nothing until the config is fixed and redeployed",
|
||||
_localNode, spec.DriverType, spec.DriverInstanceId);
|
||||
}
|
||||
if (driver is null)
|
||||
@@ -2559,15 +2103,54 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Context.Stop(adapter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an in-place config delta to a running child.
|
||||
/// <para><b>Unreachable from the reconcile path since #516</b> — a DriverConfig change is now a
|
||||
/// stop + respawn (<see cref="DriverSpawnPlanner"/>), so <c>ToApplyDelta</c> is always empty.
|
||||
/// Kept because the seam is still exercised by <c>DriverInstanceActor</c>'s own mid-connect config
|
||||
/// adoption.</para>
|
||||
/// <para><b>Two seals were removed here.</b> This used to overwrite the cached
|
||||
/// <c>Spec</c> SYNCHRONOUSLY, before the child had even dequeued the message — so the host
|
||||
/// immediately believed the new config was live, the NEXT reconcile computed no delta against it,
|
||||
/// and any drift was sealed permanently. It also <c>Tell</c>d with no <c>Receive<ApplyResult></c>
|
||||
/// handler registered, so a FAILED reinit — including Galaxy's deliberate
|
||||
/// <c>NotSupportedException</c> — dead-lettered and was never surfaced.</para>
|
||||
/// </summary>
|
||||
private void ApplyChildDelta(DriverInstanceSpec spec)
|
||||
{
|
||||
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
||||
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId()));
|
||||
// Store the full new spec — a delta can change Name, Enabled, ClusterId, etc. in addition to config.
|
||||
_children[spec.DriverInstanceId] = entry with { Spec = spec };
|
||||
var correlation = CorrelationId.NewId();
|
||||
_pendingDelta[correlation] = spec;
|
||||
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self);
|
||||
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A child's reply to <see cref="ApplyChildDelta"/>. On success the cached <c>Spec</c> is advanced —
|
||||
/// only now, when the child has actually adopted the config, so a failed reinit leaves the host
|
||||
/// believing the OLD config is live and the next reconcile re-attempts the change instead of
|
||||
/// silently treating the drift as applied. A failure is logged at Error rather than swallowed.
|
||||
/// </summary>
|
||||
private void HandleApplyResult(DriverInstanceActor.ApplyResult msg)
|
||||
{
|
||||
if (msg.Success)
|
||||
{
|
||||
if (_pendingDelta.Remove(msg.Correlation, out var spec)
|
||||
&& _children.TryGetValue(spec.DriverInstanceId, out var entry))
|
||||
{
|
||||
// A delta can change Name, Enabled, ClusterId etc. alongside the config.
|
||||
_children[spec.DriverInstanceId] = entry with { Spec = spec };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingDelta.Remove(msg.Correlation, out var failed);
|
||||
_log.Error(
|
||||
"DriverHost {Node}: driver {Id} REJECTED an in-place config change ({Reason}) — the host keeps " +
|
||||
"the previous config so the next reconcile re-attempts it",
|
||||
_localNode, failed?.DriverInstanceId ?? "<unknown>", msg.Reason ?? "no reason given");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver child finished applying an in-place <see cref="DriverInstanceActor.ApplyDelta"/> (its
|
||||
/// <see cref="IDriver.ReinitializeAsync"/> completed). Re-register THAT driver's dependency-consumer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
@@ -32,15 +33,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Default interval between bounded post-connect re-discovery passes.</summary>
|
||||
public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Default cap on the number of post-connect re-discovery passes.</summary>
|
||||
public const int DefaultRediscoverMaxAttempts = 15;
|
||||
|
||||
/// <summary>Default per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during
|
||||
/// bounded post-connect re-discovery. Bounds the mailbox suspension time; production default 30 s.</summary>
|
||||
public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30);
|
||||
/// <summary>Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
|
||||
/// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime
|
||||
/// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens.</summary>
|
||||
public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
public sealed record InitializeRequested(string DriverConfigJson);
|
||||
public sealed record InitializeSucceeded(int Generation);
|
||||
@@ -124,25 +120,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// subscription that un-gates an <see cref="IAlarmSource"/> driver's feed. Handled async so the
|
||||
/// <see cref="IAlarmSource.SubscribeAlarmsAsync"/> call is bounded + off the synchronous handlers.</summary>
|
||||
private sealed record SubscribeAlarms;
|
||||
/// <summary>Published to the parent (DriverHostActor) after each post-connect discovery pass so it can
|
||||
/// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine —
|
||||
/// the parent dedups and injection is idempotent.</summary>
|
||||
public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList<DiscoveredNode> Nodes);
|
||||
/// <summary>Self-sent when the wrapped driver raises <see cref="IRediscoverable.OnRediscoveryNeeded"/> —
|
||||
/// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread
|
||||
/// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no
|
||||
/// connection affinity (a Galaxy redeploy or a TwinCAT symbol-version bump can land while the driver is
|
||||
/// between connects), and dropping it in one state would lose the signal silently.</summary>
|
||||
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
|
||||
|
||||
/// <summary>
|
||||
/// Sent by <see cref="DriverHostActor"/> to ask this driver child to re-run post-connect discovery
|
||||
/// after the host rebinds the driver to a new equipment. Handled only in <c>Connected</c>, where it
|
||||
/// re-kicks <see cref="StartDiscovery"/> — which already honours the driver's
|
||||
/// <see cref="ITagDiscovery.RediscoverPolicy"/> and the <see cref="ITagDiscovery"/> guard, tagging the
|
||||
/// fresh pass with the current init generation. In any non-Connected state it is a deliberate no-op:
|
||||
/// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log.
|
||||
/// </summary>
|
||||
public sealed record TriggerRediscovery;
|
||||
/// <summary>Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
|
||||
/// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
|
||||
/// <see cref="QualifiesForDriftCheck"/>.</summary>
|
||||
private sealed record DriftCheckTick
|
||||
{
|
||||
public static readonly DriftCheckTick Instance = new();
|
||||
private DriftCheckTick() { }
|
||||
}
|
||||
|
||||
/// <summary>Internal self-tick driving bounded post-connect re-discovery (FixedTree populates ~0–2s after connect).
|
||||
/// <paramref name="PreviousSignature"/> is the ordered-distinct full-reference signature of the prior pass's
|
||||
/// captured set (empty string on the first tick); re-discovery stops once a non-empty set repeats it.</summary>
|
||||
private sealed record RediscoverTick(int Generation, int Attempt, string PreviousSignature);
|
||||
public sealed class RetryConnect
|
||||
{
|
||||
public static readonly RetryConnect Instance = new();
|
||||
@@ -167,19 +160,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private readonly IDriverHealthPublisher _healthPublisher;
|
||||
private readonly TimeSpan _reconnectInterval;
|
||||
|
||||
/// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests
|
||||
/// inject a tiny value so the loop runs without real-time waits.</summary>
|
||||
private readonly TimeSpan _rediscoverInterval;
|
||||
private readonly TimeSpan _healthPollInterval;
|
||||
|
||||
/// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
|
||||
/// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary>
|
||||
private readonly int _rediscoverMaxAttempts;
|
||||
|
||||
/// <summary>Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/> during bounded post-connect
|
||||
/// re-discovery. Bounds the mailbox suspension time. Production default 30 s; tests may inject a shorter
|
||||
/// value. Stored to allow injection rather than hardcoding.</summary>
|
||||
private readonly TimeSpan _rediscoverDiscoverTimeout;
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private string? _currentConfigJson;
|
||||
|
||||
@@ -203,6 +185,24 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private ISubscriptionHandle? _subscriptionHandle;
|
||||
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
|
||||
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
|
||||
private EventHandler<RediscoveryEventArgs>? _rediscoveryHandler;
|
||||
|
||||
/// <summary>When the driver last raised <see cref="IRediscoverable.OnRediscoveryNeeded"/>, and the reason
|
||||
/// it gave. Null until the first raise. Carried on every subsequent health snapshot so the AdminUI can
|
||||
/// prompt an operator to re-browse the device.
|
||||
/// <para>Deliberately <b>sticky</b> — it is not cleared on reconnect or on a later clean pass. The remote's
|
||||
/// tag set stayed changed; only an operator re-browsing and committing resolves it, and this actor cannot
|
||||
/// observe that happening. A redeploy respawns the child, which clears it naturally.</para></summary>
|
||||
private DateTime? _rediscoveryNeededUtc;
|
||||
private string? _rediscoveryReason;
|
||||
|
||||
/// <summary>Period of the tag-set drift check; tests inject a tiny value.</summary>
|
||||
private readonly TimeSpan _driftCheckInterval;
|
||||
|
||||
/// <summary>Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every
|
||||
/// check. Without this the operator's prompt would re-stamp its timestamp every interval forever,
|
||||
/// which reads as "it just happened again" rather than "it is still true".</summary>
|
||||
private string? _lastDriftSignature;
|
||||
|
||||
/// <summary>The references the host wants kept subscribed (set by <see cref="SetDesiredSubscriptions"/>).
|
||||
/// Re-applied on every entry into <c>Connected</c> so values resume after a reconnect or redeploy.</summary>
|
||||
@@ -235,11 +235,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// stub paths don't need to provide one.</param>
|
||||
/// <param name="clusterId">Optional cluster identifier forwarded in <see cref="DriverHealthChanged"/> messages;
|
||||
/// defaults to an empty string when not provided (e.g. in unit tests).</param>
|
||||
/// <param name="rediscoverInterval">Optional interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
|
||||
/// <param name="rediscoverMaxAttempts">Optional cap on re-discovery passes; defaults to 15.</param>
|
||||
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
|
||||
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
|
||||
/// <param name="driftCheckInterval">Optional period of the tag-set drift check; defaults to
|
||||
/// <see cref="DefaultDriftCheckInterval"/>. Tests inject a tiny value so the check runs without a wait.</param>
|
||||
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
|
||||
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
|
||||
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
|
||||
@@ -250,22 +249,18 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
bool startStubbed = false,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
string? clusterId = null,
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null) =>
|
||||
TimeSpan? healthPollInterval = null,
|
||||
TimeSpan? driftCheckInterval = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||
driver,
|
||||
reconnectInterval ?? DefaultReconnectInterval,
|
||||
startStubbed,
|
||||
healthPublisher ?? NullDriverHealthPublisher.Instance,
|
||||
clusterId ?? string.Empty,
|
||||
rediscoverInterval,
|
||||
rediscoverMaxAttempts,
|
||||
rediscoverDiscoverTimeout,
|
||||
invoker,
|
||||
healthPollInterval));
|
||||
healthPollInterval,
|
||||
driftCheckInterval));
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||
@@ -294,11 +289,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="startStubbed">If true, start in stub mode for testing or unavailable platforms.</param>
|
||||
/// <param name="healthPublisher">Sink for health-change notifications; must not be null.</param>
|
||||
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
|
||||
/// <param name="rediscoverInterval">Interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
|
||||
/// <param name="rediscoverMaxAttempts">Cap on the number of re-discovery passes; defaults to 15.</param>
|
||||
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
|
||||
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
|
||||
/// <param name="driftCheckInterval">Period of the tag-set drift check; defaults to
|
||||
/// <see cref="DefaultDriftCheckInterval"/>.</param>
|
||||
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
|
||||
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
|
||||
public DriverInstanceActor(
|
||||
@@ -307,21 +301,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
bool startStubbed = false,
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
string? clusterId = null,
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null)
|
||||
TimeSpan? healthPollInterval = null,
|
||||
TimeSpan? driftCheckInterval = null)
|
||||
{
|
||||
_driver = driver;
|
||||
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
||||
_driverInstanceId = driver.DriverInstanceId;
|
||||
_clusterId = clusterId ?? string.Empty;
|
||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
|
||||
_reconnectInterval = reconnectInterval;
|
||||
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
|
||||
_rediscoverMaxAttempts = rediscoverMaxAttempts;
|
||||
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
|
||||
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
||||
@@ -344,6 +334,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// Warm up the snapshot store immediately so AdminUI sees current state as soon as the
|
||||
// actor starts, before any state transition fires. Also start the periodic heartbeat so
|
||||
// long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients.
|
||||
// Attach the rediscovery signal before the first publish. Not per-connect: an IRediscoverable raise
|
||||
// has no connection affinity, and a driver can observe a remote change while disconnected.
|
||||
AttachRediscoverySource();
|
||||
PublishHealthSnapshot();
|
||||
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
||||
}
|
||||
@@ -362,9 +355,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
|
||||
// Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a
|
||||
// re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message.
|
||||
Receive<RediscoverTick>(_ => { });
|
||||
// A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it.
|
||||
Receive<TriggerRediscovery>(_ => { });
|
||||
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||
}
|
||||
|
||||
@@ -390,7 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
SubscribeDesiredAlarms();
|
||||
StartDiscovery();
|
||||
StartDriftCheck();
|
||||
});
|
||||
Receive<InitializeFailed>(msg =>
|
||||
{
|
||||
@@ -418,12 +409,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
|
||||
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
|
||||
Receive<SubscribeAlarms>(_ => { });
|
||||
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
|
||||
// already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
|
||||
Receive<RediscoverTick>(_ => { });
|
||||
// A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
|
||||
// re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
|
||||
Receive<TriggerRediscovery>(_ => { });
|
||||
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||
}
|
||||
|
||||
@@ -439,34 +425,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
||||
_driverInstanceId, msg.Reason);
|
||||
Timers.Cancel("rediscover");
|
||||
DetachSubscription();
|
||||
RecordFault();
|
||||
StopDriftCheck();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
});
|
||||
Receive<ForceReconnect>(_ =>
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
|
||||
Timers.Cancel("rediscover");
|
||||
DetachSubscription();
|
||||
StopDriftCheck();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
});
|
||||
ReceiveAsync<RediscoverTick>(HandleRediscoverAsync);
|
||||
// The host asks for a fresh discovery pass after rebinding the driver to a new equipment. Cancel any
|
||||
// pending rediscover tick FIRST — mirroring ForceReconnect/DisconnectObserved — so a stale tick left
|
||||
// over from the prior loop can't fire alongside the freshly-kicked one, then re-kick the bounded loop
|
||||
// via StartDiscovery (honours RediscoverPolicy + the ITagDiscovery guard, tagged with the current
|
||||
// _initGeneration). Only handled here in Connected — non-Connected states no-op it below. A stale tick
|
||||
// that still slips through (one already mid-async-handler) is benign: the parent dedups
|
||||
// DiscoveredNodesReady and node injection is idempotent — the Cancel just avoids the avoidable double
|
||||
// pass in the common case.
|
||||
Receive<TriggerRediscovery>(_ =>
|
||||
{
|
||||
Timers.Cancel("rediscover");
|
||||
StartDiscovery();
|
||||
});
|
||||
ReceiveAsync<WriteAttribute>(HandleWriteAsync);
|
||||
ReceiveAsync<RouteAlarmAck>(HandleAcknowledgeAsync);
|
||||
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
|
||||
@@ -486,6 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
SubscribeDesiredAlarms();
|
||||
});
|
||||
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
|
||||
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
|
||||
Receive<DataChangeForward>(OnDataChangeForward);
|
||||
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
|
||||
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
|
||||
@@ -499,6 +472,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter.
|
||||
Receive<SubscriptionFailed>(msg =>
|
||||
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
|
||||
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||
Receive<HealthPollTick>(_ =>
|
||||
{
|
||||
PublishHealthSnapshot();
|
||||
@@ -579,7 +553,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
SubscribeDesiredAlarms();
|
||||
StartDiscovery(); // re-run discovery on reconnect — keeps the injected tree fresh if the backend's capabilities changed
|
||||
StartDriftCheck();
|
||||
});
|
||||
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
|
||||
// current config; only a (generation-matched) InitializeSucceeded transitions state.
|
||||
@@ -602,12 +576,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// A SubscribeAlarms self-tell (from Connected) can be overtaken by an already-queued disconnect into
|
||||
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
|
||||
Receive<SubscribeAlarms>(_ => { });
|
||||
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
|
||||
// already-queued disconnect; swallow it — the next Connected entry re-kicks discovery.
|
||||
Receive<RediscoverTick>(_ => { });
|
||||
// A TriggerRediscovery arriving while not Connected is a deliberate no-op — the (re)connect path
|
||||
// re-runs discovery anyway. Swallow it so it stays a clean silent no-op (no Unhandled event).
|
||||
Receive<TriggerRediscovery>(_ => { });
|
||||
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||
Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval);
|
||||
}
|
||||
@@ -829,6 +798,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops the drift check. Called on every Connected exit — browsing a disconnected driver would
|
||||
/// fail every pass, and a failed browse is deliberately NOT treated as drift.</summary>
|
||||
private void StopDriftCheck() => Timers.Cancel("drift-check");
|
||||
|
||||
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
|
||||
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
|
||||
/// push data-change / alarm events to an actor that has lost its driver connection.</summary>
|
||||
@@ -853,6 +826,157 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
src.OnAlarmEvent += _alarmEventHandler;
|
||||
}
|
||||
|
||||
/// <summary>Subscribe the driver's <see cref="IRediscoverable.OnRediscoveryNeeded"/> (if it is one),
|
||||
/// marshaling each raise to the actor thread. Idempotent; mirrors <see cref="AttachAlarmSource"/>.
|
||||
/// <para>Attached once in <c>PreStart</c> rather than per-connect, because the interesting raises
|
||||
/// (a Galaxy redeploy, a TwinCAT symbol-version bump) can happen while the driver is between
|
||||
/// connects, and the event carries no connection affinity.</para></summary>
|
||||
private void AttachRediscoverySource()
|
||||
{
|
||||
if (_driver is not IRediscoverable src || _rediscoveryHandler is not null) return;
|
||||
var self = Self;
|
||||
_rediscoveryHandler = (_, e) => self.Tell(new RediscoveryRaised(e));
|
||||
src.OnRediscoveryNeeded += _rediscoveryHandler;
|
||||
}
|
||||
|
||||
/// <summary>Symmetric teardown, called from PostStop. Load-bearing: the <see cref="IDriver"/> instance
|
||||
/// can OUTLIVE this actor (the host respawns a child around the same driver object), so a missing
|
||||
/// unsubscribe would accumulate one handler per respawn, each holding a dead <c>Self</c>.</summary>
|
||||
private void DetachRediscoverySource()
|
||||
{
|
||||
if (_driver is IRediscoverable src && _rediscoveryHandler is not null)
|
||||
src.OnRediscoveryNeeded -= _rediscoveryHandler;
|
||||
_rediscoveryHandler = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this driver is worth drift-checking: it must genuinely BROWSE its remote
|
||||
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) and must report the refs its authored tags
|
||||
/// bind (<see cref="ITagDiscovery.AuthoredDiscoveryRefs"/>).
|
||||
/// <para>The first condition is what makes the check meaningful rather than reassuring. Most drivers
|
||||
/// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of <c>DiscoverAsync</c>
|
||||
/// rather than enumerating the device, so diffing the two sets for them is a tautology that would
|
||||
/// report "no drift" forever. A check that cannot fail is worse than no check: it looks like
|
||||
/// coverage.</para>
|
||||
/// </summary>
|
||||
private bool QualifiesForDriftCheck() =>
|
||||
_driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null };
|
||||
|
||||
/// <summary>Starts the slow drift check on a Connected entry, for qualifying drivers only.</summary>
|
||||
private void StartDriftCheck()
|
||||
{
|
||||
if (!QualifiesForDriftCheck()) return;
|
||||
// Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after
|
||||
// connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and
|
||||
// report phantom drift on every reconnect.
|
||||
Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises
|
||||
/// the same operator prompt an <see cref="IRediscoverable"/> raise does — this is the signal for the
|
||||
/// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic
|
||||
/// compare is the only way to notice a PLC program re-download.
|
||||
/// <para>Advisory, like every rediscovery signal: the served address space is not touched. Raw tags
|
||||
/// are authored through the <c>/raw</c> browse-commit flow, so an operator re-browses and commits.</para>
|
||||
/// </summary>
|
||||
private async Task HandleDriftCheckAsync(DriftCheckTick _)
|
||||
{
|
||||
if (_driver is not ITagDiscovery discovery) return;
|
||||
var authored = discovery.AuthoredDiscoveryRefs;
|
||||
if (authored is null) return;
|
||||
|
||||
CapturedTree tree;
|
||||
try
|
||||
{
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
// Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would
|
||||
// block writes, reconnects and health polls behind it.
|
||||
using var cts = new CancellationTokenSource(DriftBrowseTimeout);
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.Discover,
|
||||
_driverInstanceId,
|
||||
async ct => await discovery.DiscoverAsync(builder, ct),
|
||||
cts.Token);
|
||||
tree = builder.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed browse is not drift — the device may simply be unreachable, which the health surface
|
||||
// already reports. Reporting drift here would turn every comms blip into "all your tags vanished".
|
||||
_log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tree.Truncated)
|
||||
{
|
||||
// A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished.
|
||||
_log.Warning(
|
||||
"DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial",
|
||||
_driverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
var discovered = new List<string>();
|
||||
CollectLeafRefs(tree.Root, discovered);
|
||||
var drift = TagSetDriftDetector.Compare(
|
||||
discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags);
|
||||
|
||||
if (!drift.HasDrift)
|
||||
{
|
||||
// Recovered: the device matches the authored set again, so drop the prompt and let the next
|
||||
// genuine drift re-raise with a fresh timestamp.
|
||||
if (_lastDriftSignature is not null)
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set",
|
||||
_driverInstanceId);
|
||||
_lastDriftSignature = null;
|
||||
_rediscoveryNeededUtc = null;
|
||||
_rediscoveryReason = null;
|
||||
PublishHealthSnapshot();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read
|
||||
// as "it just happened again" rather than "it is still true".
|
||||
if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return;
|
||||
_lastDriftSignature = drift.Signature;
|
||||
_rediscoveryNeededUtc = DateTime.UtcNow;
|
||||
_rediscoveryReason = "device tag set differs from authored: " + drift.Describe();
|
||||
_log.Info(
|
||||
"DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged",
|
||||
_driverInstanceId, drift.Describe());
|
||||
PublishHealthSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>Per-pass timeout for the drift browse. Bounds the mailbox suspension.</summary>
|
||||
private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Flattens a captured tree to its leaf ids — the driver-side <c>FullName</c>s, which is the
|
||||
/// vocabulary <c>AuthoredDiscoveryRefs</c> is defined in.</summary>
|
||||
private static void CollectLeafRefs(CapturedNode node, List<string> into)
|
||||
{
|
||||
if (node.IsLeaf) into.Add(node.Id);
|
||||
foreach (var child in node.Children) CollectLeafRefs(child, into);
|
||||
}
|
||||
|
||||
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
|
||||
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
|
||||
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
|
||||
/// explicitly through the <c>/raw</c> browse-commit flow, so a runtime graft would create nodes no
|
||||
/// operator approved and that no deployment artifact records. This prompts a human to re-browse.</para></summary>
|
||||
private void HandleRediscoveryRaised(RediscoveryRaised msg)
|
||||
{
|
||||
_rediscoveryNeededUtc = DateTime.UtcNow;
|
||||
_rediscoveryReason = msg.Args.Reason;
|
||||
_log.Info(
|
||||
"DriverInstance {Id}: driver reports its tag set may have changed ({Reason}) — surfaced for operator re-browse; the served address space is unchanged",
|
||||
_driverInstanceId, msg.Args.Reason);
|
||||
PublishHealthSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>Symmetric teardown — called from <see cref="DetachSubscription"/> and PostStop so a stale
|
||||
/// handler never pushes to a disconnected actor.</summary>
|
||||
private void DetachAlarmSource()
|
||||
@@ -909,97 +1033,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Kick the bounded post-connect re-discovery loop on a <c>Connected</c> entry. A no-op unless the
|
||||
/// driver exposes <see cref="ITagDiscovery"/> (nothing to inject otherwise). Self-sends the first
|
||||
/// <see cref="RediscoverTick"/> tagged with the current init generation so a tick that outlives a reconnect
|
||||
/// is rejected by the generation guard in <see cref="HandleRediscoverAsync"/>.
|
||||
/// <para>Honours the driver's <see cref="ITagDiscovery.RediscoverPolicy"/>: <c>Never</c> opts out entirely
|
||||
/// (no tick scheduled); <c>Once</c> runs a single pass (the loop stops after the first publish in
|
||||
/// <see cref="HandleRediscoverAsync"/>); <c>UntilStable</c> retries each (re)connect, bounded by
|
||||
/// stop-on-stable (the discovered-set signature repeats) + the attempt cap.</para></summary>
|
||||
private void StartDiscovery()
|
||||
{
|
||||
if (_driver is not ITagDiscovery discovery) return; // driver doesn't expose discovery — nothing to inject
|
||||
if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never)
|
||||
{
|
||||
// Driver opts out of post-connect discovery — don't even schedule the first tick.
|
||||
_log.Debug("DriverInstance {Id}: RediscoverPolicy=Never — skipping post-connect discovery", _driverInstanceId);
|
||||
return;
|
||||
}
|
||||
Self.Tell(new RediscoverTick(_initGeneration, Attempt: 0, PreviousSignature: string.Empty));
|
||||
}
|
||||
|
||||
/// <summary>Runs one post-connect discovery pass: captures the driver's streamed FixedTree via a
|
||||
/// <see cref="CapturingAddressSpaceBuilder"/> and ships the result to the parent as
|
||||
/// <see cref="DiscoveredNodesReady"/> (empty/duplicate sets are fine — the parent dedups and injection
|
||||
/// is idempotent). Retries on the <see cref="_rediscoverInterval"/> until the non-empty discovered SET
|
||||
/// has STABILISED (the ordered-distinct full-reference signature repeats — robust for incremental/paged
|
||||
/// browsers where a count alone could falsely settle a partial tree) or the <see cref="_rediscoverMaxAttempts"/>
|
||||
/// cap is hit, whichever comes first; keeps retrying while empty because a FOCAS-style FixedTree cache may
|
||||
/// still be populating.
|
||||
/// <para>Limitation: this assumes a driver's discovered set only GROWS toward a stable shape (true for
|
||||
/// FOCAS — its FixedTree appears once, and on the wonder deploy the driver-config <c>_options.Tags</c> is
|
||||
/// empty so the set is 0 until the cache populates). A driver that emits an initial non-empty set and
|
||||
/// later grows could stop early on a transient repeat; acceptable for current scope.</para></summary>
|
||||
private async Task HandleRediscoverAsync(RediscoverTick tick)
|
||||
{
|
||||
if (tick.Generation != _initGeneration) return; // stale (a reconnect superseded this pass)
|
||||
if (_driver is not ITagDiscovery discovery) return;
|
||||
|
||||
IReadOnlyList<DiscoveredNode> nodes;
|
||||
try
|
||||
{
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
// Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded
|
||||
// DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it.
|
||||
using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout);
|
||||
// NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy /
|
||||
// OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell +
|
||||
// Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume
|
||||
// off-context and throw NotSupportedException("no active ActorContext"). The invoker's own
|
||||
// internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor
|
||||
// continuation still resumes on the captured actor scheduler. (Discover retries per tier.)
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.Discover,
|
||||
_driverInstanceId,
|
||||
async ct => await discovery.DiscoverAsync(builder, ct),
|
||||
cts.Token);
|
||||
nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverInstance {Id}: discovery pass {Attempt} failed; will retry", _driverInstanceId, tick.Attempt);
|
||||
nodes = Array.Empty<DiscoveredNode>();
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: under ReceiveAsync the mailbox is suspended for the whole handler, so
|
||||
// _initGeneration cannot change mid-await — the pre-await guard + Timers.Cancel("rediscover") on
|
||||
// disconnect + single-timer key reuse are the primary protections. Re-checked in case that changes.
|
||||
if (tick.Generation != _initGeneration) return;
|
||||
|
||||
Context.Parent.Tell(new DiscoveredNodesReady(_driverInstanceId, nodes));
|
||||
|
||||
// Honour the driver's re-discovery policy. A Once driver runs a single post-connect pass per
|
||||
// (re)connect regardless of whether DiscoverAsync is synchronous or async — one published pass is
|
||||
// complete, so the retry loop is skipped (no further tick scheduled). (Never never reaches here —
|
||||
// StartDiscovery returns before the first tick.) UntilStable falls through to the stop-on-stable +
|
||||
// attempt-cap logic below.
|
||||
if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Once)
|
||||
{
|
||||
_log.Debug("DriverInstance {Id}: RediscoverPolicy=Once — single discovery pass, not scheduling another", _driverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop when the non-empty discovered SET has stabilised (its signature repeats), or the attempt cap
|
||||
// is hit. Keep retrying while empty (a FixedTree cache may still be populating). First tick carries "".
|
||||
var signature = string.Join('\u0001',
|
||||
nodes.Select(n => n.FullReference).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal));
|
||||
var stableNonEmpty = nodes.Count > 0 && string.Equals(signature, tick.PreviousSignature, StringComparison.Ordinal);
|
||||
if (tick.Attempt + 1 < _rediscoverMaxAttempts && !stableNonEmpty)
|
||||
Timers.StartSingleTimer("rediscover", new RediscoverTick(tick.Generation, tick.Attempt + 1, signature), _rediscoverInterval);
|
||||
else
|
||||
_log.Debug("DriverInstance {Id}: discovery settled after {Attempt} pass(es), {Count} node(s)", _driverInstanceId, tick.Attempt + 1, nodes.Count);
|
||||
}
|
||||
|
||||
/// <summary>Records the host's desired subscription set without touching the live subscription.
|
||||
/// The set is (re)applied by <see cref="ResubscribeDesired"/> on the next <c>Connected</c> entry.</summary>
|
||||
@@ -1081,11 +1114,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
var health = _driver.GetHealth();
|
||||
var errorCount = ErrorCount5Min();
|
||||
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount);
|
||||
// _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an
|
||||
// otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount)
|
||||
// identical, so without it the dedup below would swallow the very publish that carries the
|
||||
// signal and the operator would never see the prompt.
|
||||
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount, _rediscoveryNeededUtc);
|
||||
if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint))
|
||||
return;
|
||||
_lastPublishedFingerprint = fingerprint;
|
||||
_healthPublisher.Publish(_clusterId, _driverInstanceId, health, errorCount);
|
||||
_healthPublisher.Publish(
|
||||
_clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1094,12 +1132,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <summary>Fingerprint of the last <see cref="PublishHealthSnapshot"/> call; null until first publish.</summary>
|
||||
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount)? _lastPublishedFingerprint;
|
||||
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount, DateTime? RediscoveryNeededUtc)? _lastPublishedFingerprint;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
{
|
||||
StopDriftCheck();
|
||||
DetachSubscription();
|
||||
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
|
||||
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
|
||||
DetachRediscoverySource();
|
||||
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
|
||||
@@ -7,7 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
||||
/// </summary>
|
||||
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param>
|
||||
/// <param name="ToApplyDelta">Specs whose child exists but config JSON or type differs.</param>
|
||||
/// <param name="ToApplyDelta">
|
||||
/// In-place config deltas. <b>Always empty since #516</b> — a DriverConfig change is now a
|
||||
/// stop + respawn so the factory is the single parse authority. Retained because
|
||||
/// <c>DriverInstanceActor</c> still handles <c>ApplyDelta</c> on its own config-adoption path
|
||||
/// (a config arriving mid-connect), and removing the list would hide that seam.
|
||||
/// </param>
|
||||
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
||||
public sealed record DriverSpawnPlan(
|
||||
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
||||
@@ -42,23 +47,33 @@ public static class DriverSpawnPlanner
|
||||
toStop.Add(id);
|
||||
continue;
|
||||
}
|
||||
// A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn.
|
||||
// A RESILIENCE-CONFIG change likewise forces a respawn: the CapabilityInvoker (and its
|
||||
// resolved options) is bound to the child actor at spawn time, so the only way a changed
|
||||
// ResilienceConfig takes effect is to rebuild the child. The factory invalidates the stale
|
||||
// cached pipelines on the respawn's Create call. (A pure DriverConfig change stays an
|
||||
// in-place delta — no reconnect — because it doesn't touch the resilience pipeline.)
|
||||
// ANY of the three config surfaces changing forces a stop + respawn, so the FACTORY is the
|
||||
// single parse authority for everything a driver is built from.
|
||||
//
|
||||
// • DriverType — factory-bound, can't be reinitialized in place.
|
||||
// • ResilienceConfig — the CapabilityInvoker (and its resolved options) binds to the child
|
||||
// actor at spawn time; the factory invalidates the stale cached pipelines on respawn.
|
||||
// • DriverConfig — was an in-place ApplyDelta until #516. It is now a respawn.
|
||||
//
|
||||
// #516: the in-place delta silently DISCARDED the edit on five drivers (Modbus, FOCAS,
|
||||
// OpcUaClient, AbLegacy, Sql), whose InitializeAsync served the options captured by their
|
||||
// constructor and never looked at the driverConfigJson they were handed. The deployment still
|
||||
// sealed green. Those five now re-parse (belt), and this respawn is the braces: several
|
||||
// drivers build MORE than options from config — Sql derives its dialect + connection string
|
||||
// and FOCAS its client-factory backend, both injected at construction — so an in-place
|
||||
// re-parse of options ALONE would apply a new tag set against an old connection. Only a
|
||||
// respawn rebuilds all of it.
|
||||
//
|
||||
// The accepted cost is a reconnect on every DriverConfig edit, replacing the prior
|
||||
// no-reconnect in-place path. That is deliberate: a correct reconnect beats a silent no-op.
|
||||
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal)
|
||||
|| !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal))
|
||||
|| !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal)
|
||||
|| !string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
|
||||
{
|
||||
toStop.Add(id);
|
||||
toSpawn.Add(spec);
|
||||
continue;
|
||||
}
|
||||
if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
|
||||
{
|
||||
toDelta.Add(spec);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, spec) in targetById)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// The difference between what a driver <b>discovers</b> on its remote and what an operator
|
||||
/// <b>authored</b> — i.e. whether the device's tag set has moved out from under the deployed config.
|
||||
/// </summary>
|
||||
/// <param name="Appeared">Refs the remote now offers that no authored tag binds.</param>
|
||||
/// <param name="Vanished">
|
||||
/// Refs authored tags bind that the remote no longer offers. <b>Always empty when
|
||||
/// <paramref name="VanishedDetectable"/> is false</b> — see that parameter.
|
||||
/// </param>
|
||||
/// <param name="VanishedDetectable">
|
||||
/// False when the driver re-emits its authored tags into the same discovery stream as the device-derived
|
||||
/// ones (<c>ITagDiscovery.DiscoveryStreamIncludesAuthoredTags</c>). The discovered set then always
|
||||
/// contains the authored set, so a vanished tag cannot be seen. Carried explicitly so
|
||||
/// <see cref="Describe"/> can say "new-on-device only" rather than implying a clean bill of health.
|
||||
/// </param>
|
||||
public sealed record TagSetDrift(
|
||||
IReadOnlyList<string> Appeared,
|
||||
IReadOnlyList<string> Vanished,
|
||||
bool VanishedDetectable)
|
||||
{
|
||||
/// <summary>True when the two sets differ in either direction.</summary>
|
||||
public bool HasDrift => Appeared.Count > 0 || Vanished.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// A stable identity for this drift, used to report a CHANGED drift once rather than re-reporting an
|
||||
/// unchanged one on every check. Ordinal-ordered so set enumeration order cannot make an identical
|
||||
/// drift look new.
|
||||
/// </summary>
|
||||
public string Signature { get; } =
|
||||
string.Join('', Appeared) + '' + string.Join('', Vanished);
|
||||
|
||||
/// <summary>An operator-facing one-liner naming what moved, suitable for a UI tooltip.</summary>
|
||||
/// <returns>A short human-readable description, or "no drift" when there is none.</returns>
|
||||
public string Describe()
|
||||
{
|
||||
if (!HasDrift) return "no drift";
|
||||
var parts = new List<string>(3);
|
||||
if (Appeared.Count > 0) parts.Add($"{Appeared.Count} new on device (e.g. {Sample(Appeared)})");
|
||||
if (Vanished.Count > 0) parts.Add($"{Vanished.Count} authored missing (e.g. {Sample(Vanished)})");
|
||||
// Say so rather than let the absence of a "missing" clause read as "nothing is missing".
|
||||
if (!VanishedDetectable) parts.Add("missing-tag detection unavailable for this driver");
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
/// <summary>Names at most two refs so the message stays readable when a whole PLC program is swapped.</summary>
|
||||
private static string Sample(IReadOnlyList<string> refs) =>
|
||||
refs.Count <= 2 ? string.Join(", ", refs) : $"{refs[0]}, {refs[1]}, +{refs.Count - 2} more";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares a driver's discovered refs against its authored refs. Pure and allocation-light so the
|
||||
/// actor's periodic check stays cheap; kept out of <c>DriverInstanceActor</c> so it is directly testable
|
||||
/// without an actor system.
|
||||
/// </summary>
|
||||
public static class TagSetDriftDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the two-way difference.
|
||||
/// <para><b>Ordinal comparison</b>, matching how every driver keys its tag tables — a device that
|
||||
/// genuinely exposes both <c>Speed</c> and <c>speed</c> must not have them collapsed.</para>
|
||||
/// </summary>
|
||||
/// <param name="discovered">Refs streamed by the driver's most recent discovery pass.</param>
|
||||
/// <param name="authored">Refs the driver's authored tags bind (<c>ITagDiscovery.AuthoredDiscoveryRefs</c>).</param>
|
||||
/// <param name="detectVanished">
|
||||
/// False when the driver re-emits authored tags into its discovery stream, which makes a vanished tag
|
||||
/// structurally invisible. The vanished half is then not computed at all rather than computed to a
|
||||
/// misleading empty — see <see cref="TagSetDrift.VanishedDetectable"/>.
|
||||
/// </param>
|
||||
/// <returns>The drift, which may be empty.</returns>
|
||||
public static TagSetDrift Compare(
|
||||
IReadOnlyCollection<string> discovered,
|
||||
IReadOnlyCollection<string> authored,
|
||||
bool detectVanished = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(discovered);
|
||||
ArgumentNullException.ThrowIfNull(authored);
|
||||
|
||||
var authoredSet = new HashSet<string>(authored, StringComparer.Ordinal);
|
||||
var discoveredSet = new HashSet<string>(discovered, StringComparer.Ordinal);
|
||||
|
||||
var appeared = discoveredSet.Where(r => !authoredSet.Contains(r))
|
||||
.OrderBy(r => r, StringComparer.Ordinal).ToArray();
|
||||
var vanished = detectVanished
|
||||
? authoredSet.Where(r => !discoveredSet.Contains(r))
|
||||
.OrderBy(r => r, StringComparer.Ordinal).ToArray()
|
||||
: [];
|
||||
|
||||
return new TagSetDrift(appeared, vanished, detectVanished);
|
||||
}
|
||||
}
|
||||
@@ -85,15 +85,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
public sealed record RebuildAddressSpace(
|
||||
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null);
|
||||
|
||||
/// <summary>Inject driver-discovered nodes (FixedTree) under an equipment at runtime (post-connect).</summary>
|
||||
/// <param name="EquipmentRootNodeId">The OPC UA NodeId of the equipment root folder to inject the
|
||||
/// discovered nodes under (e.g. "EQ-3686c0272279"); also the node the NodeAdded model-change is
|
||||
/// announced under.</param>
|
||||
public sealed record MaterialiseDiscoveredNodes(
|
||||
string EquipmentRootNodeId,
|
||||
IReadOnlyList<DiscoveredFolder> Folders,
|
||||
IReadOnlyList<DiscoveredVariable> Variables);
|
||||
|
||||
public sealed record ServiceLevelChanged(byte ServiceLevel);
|
||||
|
||||
private readonly IOpcUaAddressSpaceSink _sink;
|
||||
@@ -284,7 +275,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
|
||||
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
|
||||
@@ -539,28 +529,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwards driver-discovered (FixedTree) nodes to the applier so they are injected under
|
||||
/// the equipment at runtime. No-op (logged) when no applier is wired (dev/Mac/legacy seam), matching the
|
||||
/// optional-applier tolerance of <see cref="HandleRebuild"/>.</summary>
|
||||
private void HandleMaterialiseDiscovered(MaterialiseDiscoveredNodes msg)
|
||||
{
|
||||
if (_applier is null)
|
||||
{
|
||||
_log.Debug("OpcUaPublish: no applier wired — discarding MaterialiseDiscoveredNodes for {Equipment}", msg.EquipmentRootNodeId);
|
||||
return;
|
||||
}
|
||||
var failedNodes = _applier.MaterialiseDiscoveredNodes(msg.EquipmentRootNodeId, msg.Folders, msg.Variables);
|
||||
if (failedNodes > 0)
|
||||
{
|
||||
// archreview 01/S-1: a swallowed discovered-node injection failure surfaces at Error + the
|
||||
// dedicated meter instead of vanishing into per-node Warnings.
|
||||
OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair<string, object?>("kind", "nodes"));
|
||||
_log.Error(
|
||||
"OpcUaPublish: discovered-node injection DEGRADED for {Equipment} (failedNodes={FailedNodes}) — some discovered nodes may be missing",
|
||||
msg.EquipmentRootNodeId, failedNodes);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
|
||||
{
|
||||
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — <c>AbLegacyDriver.InitializeAsync</c> used to serve the options its CONSTRUCTOR
|
||||
/// captured and never look at the <c>driverConfigJson</c> it was handed, so an operator's config edit
|
||||
/// was silently discarded while the deployment still sealed green.
|
||||
/// <para>Every pre-existing reinit test in this suite passes <c>"{}"</c> — the exact input the guarded
|
||||
/// re-parser treats as "keep the constructor options" — so they were blind to the defect.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The driver validates every device's <c>HostAddress</c> during init. Reinitializing with a config
|
||||
/// whose device address is structurally invalid must therefore THROW — a driver still serving the
|
||||
/// constructor's valid device list would validate that instead and succeed, which is precisely how
|
||||
/// the discarded edit stayed invisible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_adopts_a_changed_device_list()
|
||||
{
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
|
||||
"ab1",
|
||||
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
|
||||
loggerFactory: null);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.ReinitializeAsync(
|
||||
"""{"devices":[{"hostAddress":"not-a-valid-ab-address","plcFamily":"Slc500"}]}""",
|
||||
CancellationToken.None),
|
||||
"AbLegacyDriver kept its constructor-supplied device list after a reinitialize with a changed config (#516)");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document must still keep the constructor options, so the many
|
||||
/// lifecycle tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
|
||||
{
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
|
||||
"ab1",
|
||||
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
|
||||
loggerFactory: null);
|
||||
|
||||
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — FOCAS was excluded from the first pass because it derives MORE than options from config:
|
||||
/// the <c>Backend</c> key selects its <see cref="IFocasClientFactory"/>, injected at construction. Adopting
|
||||
/// a new <c>FocasDriverOptions</c> alone would poll a NEW device/tag set through the OLD backend, which
|
||||
/// looks like it worked and does not.
|
||||
/// <para><c>ParseBinding</c> now returns options + backend together and <c>InitializeAsync</c> adopts both
|
||||
/// or neither. These tests pin the BACKEND moving — the half that was missing — because a test that only
|
||||
/// checked options would have passed against the broken version.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Reinitializing onto <c>Backend: "unimplemented"</c> must make the driver adopt that backend, whose
|
||||
/// <c>EnsureUsable()</c> throws by design. A driver still holding the constructor's <c>wire</c>
|
||||
/// backend would not throw — so the throw is a direct read of which backend is live.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_adopts_a_changed_backend_not_just_the_options()
|
||||
{
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance(
|
||||
"focas-1", """{"backend":"wire","devices":[]}""");
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
|
||||
"""{"backend":"unimplemented","devices":[]}""", CancellationToken.None));
|
||||
|
||||
ex.ShouldBeOfType<NotSupportedException>(
|
||||
"FocasDriver adopted a reinitialized config but kept its ORIGINAL backend — the half-applied "
|
||||
+ "adoption (#516) that would poll a new device/tag set through the old backend");
|
||||
}
|
||||
|
||||
/// <summary>The other direction: moving OFF the unimplemented backend must also take effect, so the
|
||||
/// adoption is not a one-way latch.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_away_from_the_unimplemented_backend_also_takes_effect()
|
||||
{
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance(
|
||||
"focas-1", """{"backend":"unimplemented","devices":[]}""");
|
||||
|
||||
// Sanity: the constructor-supplied backend is the throwing one.
|
||||
(await Record.ExceptionAsync(() => driver.InitializeAsync("{}", CancellationToken.None)))
|
||||
.ShouldBeOfType<NotSupportedException>();
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync(
|
||||
"""{"backend":"wire","devices":[]}""", CancellationToken.None));
|
||||
|
||||
ex.ShouldNotBeOfType<NotSupportedException>(
|
||||
"FocasDriver stayed on the unimplemented backend after a reinitialize that selected 'wire'");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document keeps the constructor-supplied pair, so the lifecycle tests
|
||||
/// that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_backend()
|
||||
{
|
||||
var driver = FocasDriverFactoryExtensions.CreateInstance(
|
||||
"focas-1", """{"backend":"unimplemented","devices":[]}""");
|
||||
|
||||
(await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)))
|
||||
.ShouldBeOfType<NotSupportedException>();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — <see cref="ModbusDriver.InitializeAsync"/> used to serve the options its CONSTRUCTOR
|
||||
/// captured and never look at the <c>driverConfigJson</c> it was handed, so an operator's config edit
|
||||
/// was silently discarded while the deployment still sealed green.
|
||||
/// <para><b>This test passes a CHANGED config on purpose.</b> Every pre-existing reinit test in this
|
||||
/// suite passes <c>"{}"</c> — exactly the input the guarded re-parser treats as "keep the constructor
|
||||
/// options" — so those tests were blind to the defect by construction.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>The transport factory receives the options the driver actually decided to use, so it is a
|
||||
/// direct read of which config won — no log-string matching.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_a_changed_host_rebuilds_the_transport_against_the_new_host()
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}""");
|
||||
var driver = new ModbusDriver(options, "m1", opts =>
|
||||
{
|
||||
seen.Add($"{opts.Host}:{opts.Port}");
|
||||
return new NeverConnectingTransport();
|
||||
});
|
||||
|
||||
try { await driver.InitializeAsync("""{"host":"10.0.0.1","port":502}""", CancellationToken.None); }
|
||||
catch { /* connect failure is expected and irrelevant */ }
|
||||
|
||||
try { await driver.ReinitializeAsync("""{"host":"10.0.0.99","port":1502}""", CancellationToken.None); }
|
||||
catch { /* ditto */ }
|
||||
|
||||
seen.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
seen[^1].ShouldBe("10.0.0.99:1502",
|
||||
"ModbusDriver kept its constructor-supplied host after a reinitialize with a changed config (#516)");
|
||||
}
|
||||
|
||||
/// <summary>A placeholder / empty document must still keep the constructor options — the guard exists so
|
||||
/// the many lifecycle tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
|
||||
{
|
||||
var seen = new List<string>();
|
||||
var options = ModbusDriverFactoryExtensions.ParseOptions("m1", """{"host":"10.0.0.1","port":502}""");
|
||||
var driver = new ModbusDriver(options, "m1", opts =>
|
||||
{
|
||||
seen.Add($"{opts.Host}:{opts.Port}");
|
||||
return new NeverConnectingTransport();
|
||||
});
|
||||
|
||||
try { await driver.ReinitializeAsync("{}", CancellationToken.None); }
|
||||
catch { /* connect failure is expected */ }
|
||||
|
||||
seen.ShouldAllBe(s => s == "10.0.0.1:502");
|
||||
}
|
||||
|
||||
private sealed class NeverConnectingTransport : IModbusTransport
|
||||
{
|
||||
public bool IsConnected => false;
|
||||
public Task ConnectAsync(CancellationToken cancellationToken)
|
||||
=> throw new IOException("test transport never connects");
|
||||
public Task DisconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken cancellationToken)
|
||||
=> throw new IOException("test transport never connects");
|
||||
public void Dispose() { }
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — <c>OpcUaClientDriver.InitializeAsync</c> used to serve the options its CONSTRUCTOR
|
||||
/// captured. Its own comment claimed "resolving on every InitializeAsync … picks up rotations", which
|
||||
/// was true of SECRET rotation only: the endpoint, the security policy and the tag set were all frozen
|
||||
/// at construction, so an operator's edit was discarded while the deployment still sealed green.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientReinitConfigAdoptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>ValidateNamespaceKind</c> runs at the top of init and rejects <c>TargetNamespaceKind=Equipment</c>
|
||||
/// with an empty UNS mapping table. Reinitializing INTO that shape must therefore throw — a driver
|
||||
/// still serving the constructor's valid options would validate those and succeed, which is exactly
|
||||
/// how the discarded edit stayed invisible.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_adopts_a_changed_config()
|
||||
{
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
|
||||
"opc1",
|
||||
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => driver.ReinitializeAsync(
|
||||
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{}}""",
|
||||
CancellationToken.None),
|
||||
"OpcUaClientDriver kept its constructor-supplied options after a reinitialize with a changed config (#516)");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document must still keep the constructor options.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
|
||||
{
|
||||
var driver = OpcUaClientDriverFactoryExtensions.CreateInstance(
|
||||
"opc1",
|
||||
"""{"endpointUrl":"opc.tcp://10.0.0.1:4840","targetNamespaceKind":"Equipment","unsMappingTable":{"a":"b"}}""");
|
||||
|
||||
// "{}" keeps the (valid) constructor options, so ValidateNamespaceKind passes and the driver gets as
|
||||
// far as the connect — which fails against an unreachable endpoint. Anything BUT the validation
|
||||
// throw proves the constructor options survived.
|
||||
var ex = await Record.ExceptionAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
|
||||
(ex is InvalidOperationException ioe && ioe.Message.Contains("UnsMappingTable", StringComparison.OrdinalIgnoreCase))
|
||||
.ShouldBeFalse("an empty document must keep the constructor-supplied options, not re-validate an empty config");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #516 — Sql was excluded from the first pass because it derives MORE than options from config
|
||||
/// (its <see cref="ISqlDialect"/> and its resolved connection string are both config-derived), so
|
||||
/// adopting a new <c>SqlDriverOptions</c> alone would poll a NEW tag set through the OLD database.
|
||||
/// A half-applied config change is worse than a discarded one, because it looks like it worked.
|
||||
/// <para>The fix is <c>ParseBinding</c> + <c>ApplyBinding</c>: one parse produces all three, and they are
|
||||
/// adopted together. These tests pin the ATOMICITY, not just that a re-parse happens — that is the
|
||||
/// property that made this driver special.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SqlReinitConfigAdoptionTests
|
||||
{
|
||||
private const string RefA = "SqlReinitTestA";
|
||||
private const string RefB = "SqlReinitTestB";
|
||||
|
||||
/// <summary>
|
||||
/// A reinitialize that changes <c>connectionStringRef</c> must move the driver onto the NEW database.
|
||||
/// <c>Endpoint</c> is the credential-free rendering of the live connection string, so it is a direct
|
||||
/// read of which connection the driver actually adopted — no log-string matching.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_adopts_a_changed_connection_string_not_just_the_options()
|
||||
{
|
||||
using var _ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
|
||||
"Server=old-server;Database=OldDb;Integrated Security=true");
|
||||
using var __ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefB,
|
||||
"Server=new-server;Database=NewDb;Integrated Security=true");
|
||||
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
|
||||
driver.Endpoint.ShouldContain("old-server");
|
||||
|
||||
// The database is unreachable, so init faults — irrelevant here. The assertion is which database it
|
||||
// tried, i.e. whether the connection string moved with the options.
|
||||
try
|
||||
{
|
||||
await driver.ReinitializeAsync(
|
||||
$$"""{"provider":"SqlServer","connectionStringRef":"{{RefB}}"}""", CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Liveness failure against a non-existent server is expected.
|
||||
}
|
||||
|
||||
driver.Endpoint.ShouldContain(
|
||||
"new-server",
|
||||
customMessage:
|
||||
"SqlDriver adopted a reinitialized config but kept its ORIGINAL connection string — the exact "
|
||||
+ "half-applied adoption (#516) that would poll a new tag set against the old database");
|
||||
driver.Endpoint.ShouldNotContain("old-server");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A config whose <c>connectionStringRef</c> names an unprovisioned environment variable must make
|
||||
/// the whole reinitialize FAIL, leaving the previous binding intact — never a partial adoption where
|
||||
/// new options are live against the old connection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reinitialize_that_cannot_resolve_its_connection_leaves_the_previous_binding_intact()
|
||||
{
|
||||
using var _ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
|
||||
"Server=old-server;Database=OldDb;Integrated Security=true");
|
||||
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => driver.ReinitializeAsync(
|
||||
"""{"provider":"SqlServer","connectionStringRef":"SqlReinitTestNeverProvisioned"}""",
|
||||
CancellationToken.None));
|
||||
|
||||
driver.Endpoint.ShouldContain(
|
||||
"old-server",
|
||||
customMessage:
|
||||
"a reinitialize that could not resolve its new connection must leave the previous binding whole");
|
||||
}
|
||||
|
||||
/// <summary>An empty/placeholder document keeps the constructor-supplied binding, so the many lifecycle
|
||||
/// tests that pass <c>"{}"</c> keep meaning what they meant.</summary>
|
||||
[Fact]
|
||||
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_binding()
|
||||
{
|
||||
using var _ = new ScopedEnvironmentVariable(
|
||||
SqlConnectionStringResolver.EnvironmentVariablePrefix + RefA,
|
||||
"Server=old-server;Database=OldDb;Integrated Security=true");
|
||||
|
||||
var driver = SqlDriverFactoryExtensions.CreateInstance(
|
||||
"sql-1", $$"""{"provider":"SqlServer","connectionStringRef":"{{RefA}}"}""", loggerFactory: null);
|
||||
|
||||
try { await driver.ReinitializeAsync("{}", CancellationToken.None); }
|
||||
catch { /* liveness failure expected */ }
|
||||
|
||||
driver.Endpoint.ShouldContain("old-server");
|
||||
}
|
||||
|
||||
/// <summary>Sets an environment variable for the duration of a test and restores it after. The resolver
|
||||
/// reads process-global state, so a leaked variable would race every sibling test.</summary>
|
||||
private sealed class ScopedEnvironmentVariable : IDisposable
|
||||
{
|
||||
private readonly string _name;
|
||||
private readonly string? _previous;
|
||||
|
||||
public ScopedEnvironmentVariable(string name, string value)
|
||||
{
|
||||
_name = name;
|
||||
_previous = Environment.GetEnvironmentVariable(name);
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Closes <c>deferment.md</c> G-4. A guard already existed for <c>DriverTypeNames</c> ↔ the <c>/raw</c>
|
||||
/// driver PICKER, but not for the two dispatch maps downstream of it, so a driver could be registered,
|
||||
/// offered in the picker, and then have no config form — which is exactly what happened to
|
||||
/// <c>Calculation</c> (G-1) and to Sql/MTConnect/Calculation on the device modal (G-2). Both survived
|
||||
/// review because nothing could see them.
|
||||
/// <para>These are ordinary map lookups rather than reflection over a private field: the two modals were
|
||||
/// converted from a Razor <c>@switch</c> — unreachable from a test, since it compiles into
|
||||
/// <c>BuildRenderTree</c>'s IL — to <c>DriverConfigFormMap</c> / <c>DeviceFormMap</c>. There is no bUnit
|
||||
/// in this project, so a map is the only thing a test can hold on to.</para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverFormMapParityTests
|
||||
{
|
||||
/// <summary>Every declared driver type must have a typed config form. There is no exemption: a driver
|
||||
/// with no configurable surface still needs a form to say so, or the operator meets a bare warning.</summary>
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_has_a_typed_config_form()
|
||||
{
|
||||
var missing = DriverTypeNames.All
|
||||
.Where(t => DriverConfigFormMap.Resolve(t) is null)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"these driver types are registered and offered in the /raw picker but DriverConfigModal has no "
|
||||
+ $"form for them, so their config is unauthorable through the UI: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
/// <summary>The reverse direction — a mapped type that is no longer a declared driver type is a stale
|
||||
/// entry, and would keep a deleted driver's form reachable.</summary>
|
||||
[Fact]
|
||||
public void Every_config_form_maps_to_a_declared_driver_type()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var stale = DriverConfigFormMap.MappedDriverTypes
|
||||
.Where(t => !declared.Contains(t))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
stale.ShouldBeEmpty($"DriverConfigFormMap has entries for undeclared driver types: {string.Join(", ", stale)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every declared driver type must EITHER have a typed device form OR be declared single-connection.
|
||||
/// <para>The either/or matters: demanding a device form from every driver would be wrong (Sql has one
|
||||
/// connection string, MTConnect one Agent URI, Calculation no connection at all), and a test that has
|
||||
/// to be suppressed for five drivers stops being read. Forcing the choice to be explicit is what makes
|
||||
/// this a guard — a new driver cannot silently fall through.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_has_a_device_form_or_is_declared_single_connection()
|
||||
{
|
||||
var unaccounted = DriverTypeNames.All
|
||||
.Where(t => DeviceFormMap.Resolve(t) is null && !DeviceFormMap.IsSingleConnection(t))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
unaccounted.ShouldBeEmpty(
|
||||
$"these driver types have neither a typed device form nor a place in "
|
||||
+ $"DeviceFormMap.SingleConnectionDriverTypes, so DeviceModal falls through to its "
|
||||
+ $"\"no typed device form\" warning: {string.Join(", ", unaccounted)}");
|
||||
}
|
||||
|
||||
/// <summary>The reverse direction for the device map.</summary>
|
||||
[Fact]
|
||||
public void Every_device_form_maps_to_a_declared_driver_type()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var stale = DeviceFormMap.MappedDriverTypes
|
||||
.Concat(DeviceFormMap.SingleConnectionDriverTypes)
|
||||
.Where(t => !declared.Contains(t))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
stale.ShouldBeEmpty($"DeviceFormMap references undeclared driver types: {string.Join(", ", stale)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver that authors its connection on the DRIVER form must have a driver form to author it on.
|
||||
/// Without this, declaring a type single-connection would be a way to opt out of both maps at once
|
||||
/// and leave the connection unauthorable anywhere.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_single_connection_driver_has_a_driver_config_form()
|
||||
{
|
||||
var missing = DeviceFormMap.SingleConnectionDriverTypes
|
||||
.Where(t => DriverConfigFormMap.Resolve(t) is null)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"these types are declared single-connection but have no driver config form, so their connection "
|
||||
+ $"cannot be authored anywhere: {string.Join(", ", missing)}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parity guards for the two remaining per-driver dispatch maps (<c>deferment.md</c> G-5, G-6).
|
||||
/// Both were already testable structures — unlike the two Razor <c>@switch</c> blocks that needed
|
||||
/// extracting first — so nothing but the tests was missing.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverDispatchMapParityTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver types with a typed tag editor but no CSV map, so import/export silently degrades them to
|
||||
/// the raw <c>TagConfigJson</c> fallback. Empty — every editor-backed driver now has typed columns.
|
||||
/// <para>Deliberately expressed as "has a typed editor ⇒ has typed CSV columns" rather than
|
||||
/// "every driver type", because a driver with no typed editor has nothing to project into columns.
|
||||
/// Tying the two maps together is what makes this catch the next omission.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_driver_with_a_typed_tag_editor_has_typed_csv_columns()
|
||||
{
|
||||
var missing = DriverTypeNames.All
|
||||
.Where(t => TagConfigEditorMap.Resolve(t) is not null && CsvColumnMap.Resolve(t) is null)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"these driver types have a typed tag editor but no CsvColumnMap entry, so CSV import/export "
|
||||
+ $"drops them to the raw TagConfigJson fallback: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
/// <summary>A CSV map for a driver type that no longer exists is a stale entry.</summary>
|
||||
[Fact]
|
||||
public void Every_csv_map_targets_a_declared_driver_type()
|
||||
{
|
||||
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var stale = CsvColumnMap.All
|
||||
.Select(m => m.DriverType)
|
||||
.Where(t => !declared.Contains(t))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
stale.ShouldBeEmpty($"CsvColumnMap has entries for undeclared driver types: {string.Join(", ", stale)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every driver type either produces a TYPED blob from browse-commit, or is on the documented
|
||||
/// non-browsable list. The generic <c>{"address": …}</c> fallback opens EMPTY in a typed editor and
|
||||
/// blanks the field on save — the MTConnect branch was added for exactly that, and Sql had the same
|
||||
/// bug unnoticed (<c>deferment.md</c> G-6) because the fallback's comment claimed "browsable drivers
|
||||
/// are all handled above", which stopped being true the moment <c>SqlDriverBrowser</c> registered.
|
||||
/// <para><b>Asserted in both directions on purpose.</b> A one-way check would let a newly-browsable
|
||||
/// driver be quietly added to the exclusion list instead of getting a branch. Requiring the two sets
|
||||
/// to match exactly means a driver gaining or losing a browser forces this list to be revisited.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Browse_commit_falls_back_to_the_generic_address_key_for_exactly_the_non_browsable_drivers()
|
||||
{
|
||||
// Address fields as a browser supplies them; a driver whose browser states none must still produce
|
||||
// something its typed editor can read from the leaf name alone.
|
||||
var addressFields = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["schema"] = "dbo",
|
||||
["table"] = "Readings",
|
||||
["columnName"] = "Speed",
|
||||
};
|
||||
|
||||
var fellBack = DriverTypeNames.All
|
||||
.Where(t => RawBrowseCommitMapper.BuildTagConfig(t, "Speed", addressFields) == """{"address":"Speed"}""")
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
fellBack.ShouldBe(
|
||||
NonBrowsableDriverTypes.OrderBy(t => t, StringComparer.Ordinal).ToList(),
|
||||
$"the set of driver types committing to the generic \"address\" key drifted. Extra entries are "
|
||||
+ $"browsable drivers whose typed editor will open EMPTY and blank the field on save; missing "
|
||||
+ $"entries gained a typed branch and should leave this list. Got: {string.Join(", ", fellBack)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver types with no browser, for which the generic <c>address</c> key is the correct commit.
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Modbus</b> — register addresses are numeric coordinates, not a browsable namespace.</item>
|
||||
/// <item><b>Calculation</b> — a pseudo-driver over other tags' RawPaths; there is no device.</item>
|
||||
/// </list>
|
||||
/// Adding a type here is a claim that it cannot be browsed. If it can, give it a branch instead.
|
||||
/// </summary>
|
||||
private static readonly string[] NonBrowsableDriverTypes =
|
||||
[
|
||||
DriverTypeNames.Modbus,
|
||||
DriverTypeNames.Calculation,
|
||||
];
|
||||
}
|
||||
+37
-12
@@ -17,18 +17,43 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
public sealed class TagConfigDriverTypeNameGuardTests
|
||||
{
|
||||
// Every DriverTypeNames constant that has a typed editor registered.
|
||||
public static TheoryData<string> EditorMappedDriverTypes() =>
|
||||
[
|
||||
DriverTypeNames.Modbus,
|
||||
DriverTypeNames.S7,
|
||||
DriverTypeNames.AbCip,
|
||||
DriverTypeNames.AbLegacy,
|
||||
DriverTypeNames.TwinCAT,
|
||||
DriverTypeNames.FOCAS,
|
||||
DriverTypeNames.OpcUaClient,
|
||||
DriverTypeNames.Calculation,
|
||||
DriverTypeNames.MTConnect,
|
||||
];
|
||||
//
|
||||
// Enumerated FROM the map rather than hand-written. The hand-written list this replaced guarded
|
||||
// against RENAMES but not against GAPS: a newly-added DriverTypeNames constant simply never appeared
|
||||
// here, so the guard stayed green while the map went unmapped — and it had already silently drifted,
|
||||
// omitting Galaxy, Sql and Mqtt. Pairing it with the coverage test below makes both directions real.
|
||||
public static TheoryData<string> EditorMappedDriverTypes()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var t in DriverTypeNames.All.Where(t => TagConfigEditorMap.Resolve(t) is not null))
|
||||
{
|
||||
data.Add(t);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coverage direction: every declared driver type has a typed tag editor, except those documented as
|
||||
/// having none. Without this, enumerating the map above proves only that the map agrees with itself.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_declared_driver_type_has_a_typed_tag_editor_or_is_documented_as_raw_json()
|
||||
{
|
||||
// Galaxy authors its tag reference as a dotted FullName through the Galaxy address picker rather
|
||||
// than a typed field editor, so it uses the generic raw-TagConfig-JSON textarea by design.
|
||||
string[] rawJsonByDesign = [DriverTypeNames.Galaxy];
|
||||
|
||||
var missing = DriverTypeNames.All
|
||||
.Where(t => TagConfigEditorMap.Resolve(t) is null)
|
||||
.Except(rawJsonByDesign, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
missing.ShouldBeEmpty(
|
||||
$"these driver types have no typed tag editor and are not documented as raw-JSON by design, so "
|
||||
+ $"the /uns TagModal falls back to a bare JSON textarea for them: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(EditorMappedDriverTypes))]
|
||||
|
||||
-5
@@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
|
||||
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
|
||||
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
|
||||
applier.MaterialiseDiscoveredNodes(
|
||||
"eq-1",
|
||||
Array.Empty<DiscoveredFolder>(),
|
||||
new[] { new DiscoveredVariable("eq-1/D", "eq-1", "D", "Float", Writable: false, IsArray: false, ArrayLength: null) })
|
||||
.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------------- fixtures ----------------
|
||||
|
||||
@@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class AddressSpaceApplierTests
|
||||
{
|
||||
/// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
|
||||
[Fact]
|
||||
public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var outcome = applier.Apply(EmptyPlan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
outcome.AddedNodes.ShouldBe(0);
|
||||
outcome.RemovedNodes.ShouldBe(0);
|
||||
outcome.ChangedNodes.ShouldBe(0);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — removed equipment is a PureRemove: the applier writes each id's terminal
|
||||
/// "no-event" condition state (top-of-Apply block) then tears down each equipment SUBTREE in place via
|
||||
/// RemoveEquipmentSubtree — NO full rebuild (other clients' subscriptions survive). (Supersedes the
|
||||
/// pre-R2-07 "removed equipment ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Removed_equipment_writes_terminal_state_and_removes_subtree_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = WithEquipmentRemoval("eq-1", "eq-2");
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RemovedNodes.ShouldBe(2);
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
// Terminal "no-event" condition state written per id (inactive + acked + confirmed).
|
||||
sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
|
||||
sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
|
||||
// Each equipment torn down as a subtree.
|
||||
sink.RemoveCalls.OrderBy(x => x.NodeId).ShouldBe(new[] { ("equipment", "eq-1"), ("equipment", "eq-2") });
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — added equipment is a PureAdd: the applier SKIPS the rebuild (the idempotent
|
||||
/// Materialise passes create the new folder; existing client subscriptions survive) and writes no alarm
|
||||
/// state. (Supersedes the pre-R2-07 "added equipment ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Added_equipment_is_pure_add_and_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = new AddressSpacePlan(
|
||||
AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown, subscriptions preserved
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.AlarmWrites.ShouldBeEmpty();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
|
||||
[Fact]
|
||||
public void Driver_only_changes_do_not_trigger_address_space_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = new AddressSpacePlan(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: new[]
|
||||
{
|
||||
new AddressSpacePlan.DriverDelta(
|
||||
new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
|
||||
new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
|
||||
},
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.</summary>
|
||||
[Fact]
|
||||
public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires()
|
||||
{
|
||||
var sink = new ThrowingSink(throwOnAlarmWrite: true);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = WithEquipmentRemoval("eq-1");
|
||||
|
||||
var outcome = applier.Apply(plan); // should not throw
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
|
||||
/// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
|
||||
/// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
|
||||
/// folder (decision #4).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
EquipmentNodes: Array.Empty<EquipmentNode>(),
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
|
||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
|
||||
/// folder (not the namespace root), with the variable parented to that sub-folder and a
|
||||
/// folder-scoped NodeId.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||
// A Read plan threads Writable: false (the node stays CurrentRead).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
|
||||
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||
}
|
||||
|
||||
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
|
||||
/// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one
|
||||
/// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
sink.VariableCalls.Count.ShouldBe(2);
|
||||
sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float", false));
|
||||
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float", false));
|
||||
}
|
||||
|
||||
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag (<c>Alarm is not null</c>) materialises a
|
||||
/// real OPC UA Part 9 condition node (via the same path scripted alarms use) instead of a value
|
||||
/// variable; a plain tag (<c>Alarm == null</c>) stays a value variable. The alarm tag's condition
|
||||
/// uses the tag's folder-scoped NodeId, the equipment folder as parent, and carries the tag's
|
||||
/// AlarmType/Severity. Proves BOTH branches in one composition.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_alarm_bearing_tag_becomes_condition_plain_tag_stays_variable()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-plain", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
|
||||
var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
|
||||
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
|
||||
|
||||
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
|
||||
// matching display/type/severity) and did NOT drive EnsureVariable.
|
||||
var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
|
||||
// A native equipment-tag alarm: the call-site threads isNative: true.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
|
||||
sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId);
|
||||
}
|
||||
|
||||
/// <summary>Phase B WS-3 — an alarm-bearing equipment tag WITH a FolderPath still gets its
|
||||
/// sub-folder created, and its condition is parented to that sub-folder (not the equipment folder),
|
||||
/// using the folder-scoped NodeId.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_alarm_bearing_tag_with_FolderPath_conditions_under_subfolder()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-alarm", "eq-1", "drv", FolderPath: "Diagnostics", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 500)),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
// The sub-folder is still created for an alarm tag with a FolderPath.
|
||||
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
|
||||
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
|
||||
var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
|
||||
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
|
||||
sink.VariableCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Phase C Task 2 — the applier resolves the historian tagname per value tag and threads it
|
||||
/// to <c>EnsureVariable</c>: a historized tag with NO override falls back to its <c>FullName</c>; a
|
||||
/// historized tag WITH an override passes the override verbatim; a non-historized tag passes null.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_resolves_historian_tagname_default_override_and_null()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
// Historized, no override ⇒ tagname defaults to FullName ("T.A").
|
||||
new EquipmentTagPlan("tag-def", "eq-1", "drv", FolderPath: "", Name: "ADefault", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null),
|
||||
// Historized, override ⇒ tagname is the override ("WW.Override"), NOT FullName.
|
||||
new EquipmentTagPlan("tag-ovr", "eq-1", "drv", FolderPath: "", Name: "BOverride", DataType: "Float",
|
||||
FullName: "T.B", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Override"),
|
||||
// Not historized ⇒ tagname is null.
|
||||
new EquipmentTagPlan("tag-no", "eq-1", "drv", FolderPath: "", Name: "CPlain", DataType: "Float",
|
||||
FullName: "T.C", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
|
||||
byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
|
||||
byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
|
||||
byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
|
||||
}
|
||||
|
||||
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
|
||||
/// <c>FullName</c> (the resolve uses <c>string.IsNullOrWhiteSpace</c>, not just null).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_blank_override_falls_back_to_full_name()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-blank", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: " "),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.HistorianCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.HistorianTagname.ShouldBe("40001");
|
||||
}
|
||||
|
||||
/// <summary>Array-support Task 2 — an <see cref="EquipmentTagPlan"/> with <c>IsArray: true,
|
||||
/// ArrayLength: 16</c> flowing through <see cref="AddressSpaceApplier.MaterialiseEquipmentTags"/> must
|
||||
/// forward BOTH flags verbatim to the sink's <c>EnsureVariable</c>. Guards against arg-order swaps or
|
||||
/// accidental drops in the wire-through.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_array_plan_forwards_isArray_and_arrayLength_to_sink()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-arr", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
|
||||
call.IsArray.ShouldBeTrue();
|
||||
call.ArrayLength.ShouldBe(16u);
|
||||
}
|
||||
|
||||
/// <summary>Array-support Task 2 — a scalar <see cref="EquipmentTagPlan"/> (<c>IsArray: false</c>,
|
||||
/// <c>ArrayLength: null</c>) must pass <c>isArray == false</c> through to the sink. Guards against a
|
||||
/// default flip that would silently materialise scalar tags as 1-D arrays.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_scalar_plan_forwards_isArray_false_to_sink()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-scalar", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "40002", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var call = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.IsArray.ShouldBeFalse();
|
||||
call.ArrayLength.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Review M-1 — an array equipment tag authored with <c>Writable: true</c> must be
|
||||
/// materialised as READ-ONLY (<c>writable == false</c>) because array writes are out of scope
|
||||
/// (Phase 4c read-only surface). The driver write path does not handle arrays and would crash
|
||||
/// (e.g. S7 BoxValueForWrite). Guards against a future refactor that accidentally enables the
|
||||
/// writable path for arrays.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_array_writable_true_is_forced_read_only()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
// Authored ReadWrite AND IsArray — the applier must clamp to read-only.
|
||||
new EquipmentTagPlan("tag-arr-rw", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: true, Alarm: null, IsArray: true, ArrayLength: 8u),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
// writable must be false (array writes out of scope), isArray must be true (forwarded verbatim).
|
||||
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
|
||||
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
|
||||
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
arrCall.IsArray.ShouldBeTrue();
|
||||
arrCall.ArrayLength.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>Review M-1 regression — a scalar tag authored with <c>Writable: true</c> must still
|
||||
/// be materialised as read/write (<c>writable == true</c>). The array-clamp must NOT affect
|
||||
/// scalar tags.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_scalar_writable_true_stays_writable()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
// Authored ReadWrite, scalar — must pass through writable: true unchanged.
|
||||
new EquipmentTagPlan("tag-scalar-rw", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "40002", Writable: true, Alarm: null, IsArray: false, ArrayLength: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
|
||||
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
|
||||
varCall.Writable.ShouldBeTrue(); // scalar: writable unchanged
|
||||
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
arrCall.IsArray.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
|
||||
/// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
|
||||
/// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create
|
||||
/// the equipment folder (no sub-folder when FolderPath is empty).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
|
||||
// VirtualTags are computed outputs — always read-only (Writable: false).
|
||||
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
|
||||
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
|
||||
}
|
||||
|
||||
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
|
||||
/// the equipment-VirtualTag pass is byte-identical to <c>V3NodeIds.Uns</c> — the
|
||||
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
|
||||
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
|
||||
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
|
||||
/// shared helper fails here.</summary>
|
||||
[Fact]
|
||||
public void Materialised_variable_node_ids_match_shared_EquipmentNodeIds_formula()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-flat", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-nested", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-flat", "eq-2", FolderPath: "", Name: "Efficiency", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
|
||||
new EquipmentVirtualTagPlan("vt-nested", "eq-2", FolderPath: "Calc", Name: "Avg", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
|
||||
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
|
||||
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
|
||||
}
|
||||
|
||||
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
|
||||
/// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
|
||||
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseEquipmentVirtualTags(composition);
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty();
|
||||
sink.VariableCalls.Count.ShouldBe(2);
|
||||
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
|
||||
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64", false));
|
||||
}
|
||||
|
||||
/// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
|
||||
/// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
|
||||
/// disabled alarms.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentScriptedAlarms = new[]
|
||||
{
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
|
||||
Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true),
|
||||
new EquipmentScriptedAlarmPlan(
|
||||
ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
|
||||
Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
|
||||
DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: false),
|
||||
},
|
||||
};
|
||||
|
||||
applier.MaterialiseScriptedAlarms(composition);
|
||||
|
||||
// Only the enabled alarm is materialised; the disabled one is skipped entirely.
|
||||
// A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap).
|
||||
sink.AlarmConditionCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false));
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — MaterialiseDiscoveredNodes ensures the discovered folders PARENT-FIRST (ordered by
|
||||
/// depth = '/' count) and the discovered variables at their folder-scoped NodeIds/parents, with variables
|
||||
/// created READ-ONLY (writable == false), then raises EXACTLY ONE NodeAdded model-change under the
|
||||
/// equipment root. Folders are passed in REVERSE (child-first) to prove the applier re-orders them
|
||||
/// parent-first before ensuring (a child folder's parent must exist first).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_ensures_folders_parent_first_read_only_variables_and_raises_model_change_once()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
// Child folder listed BEFORE its parent — the applier must re-order parent-first.
|
||||
var folders = new[]
|
||||
{
|
||||
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
|
||||
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
|
||||
};
|
||||
var variables = new[]
|
||||
{
|
||||
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
|
||||
"String", Writable: false, IsArray: false, ArrayLength: null),
|
||||
};
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
|
||||
|
||||
// Folders ensured parent-first regardless of input order (shallowest depth first).
|
||||
sink.FolderCalls.Select(f => f.NodeId).ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
|
||||
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS", "EQ-1", "FOCAS"));
|
||||
sink.FolderCalls.ShouldContain(("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"));
|
||||
|
||||
// Variable ensured at its folder-scoped NodeId, parented to its sub-folder, READ-ONLY.
|
||||
sink.VariableCalls.ShouldHaveSingleItem()
|
||||
.ShouldBe(("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber", "String", false));
|
||||
|
||||
// Exactly one NodeAdded model-change, announced under the equipment root.
|
||||
sink.ModelChangeCalls.ShouldHaveSingleItem().ShouldBe("EQ-1");
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — a discovered array variable (rare) authored <c>Writable: true</c> is forced
|
||||
/// READ-ONLY (mirrors MaterialiseEquipmentTags: the driver write path can't handle arrays), while the
|
||||
/// IsArray / ArrayLength flags are forwarded verbatim to the sink.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_array_variable_is_forced_read_only()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var variables = new[]
|
||||
{
|
||||
new DiscoveredVariable("EQ-1/FOCAS/Buffer", "EQ-1", "Buffer", "Int16",
|
||||
Writable: true, IsArray: true, ArrayLength: 8u),
|
||||
};
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), variables);
|
||||
|
||||
var varCall = sink.VariableCalls.ShouldHaveSingleItem();
|
||||
varCall.Writable.ShouldBeFalse(); // clamped to read-only despite Writable: true
|
||||
var arrCall = sink.ArrayCalls.ShouldHaveSingleItem();
|
||||
arrCall.IsArray.ShouldBeTrue();
|
||||
arrCall.ArrayLength.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — re-applying the SAME discovered plan is idempotent-SAFE: it does not throw, the
|
||||
/// distinct folder/variable set the applier issues per pass is stable (the real sink early-returns on
|
||||
/// existing nodes), and a model-change is raised once PER call (twice across two calls).</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_is_idempotent_safe_on_repeated_application()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var folders = new[]
|
||||
{
|
||||
new DiscoveredFolder("EQ-1/FOCAS", "EQ-1", "FOCAS"),
|
||||
new DiscoveredFolder("EQ-1/FOCAS/Identity", "EQ-1/FOCAS", "Identity"),
|
||||
};
|
||||
var variables = new[]
|
||||
{
|
||||
new DiscoveredVariable("EQ-1/FOCAS/Identity/SeriesNumber", "EQ-1/FOCAS/Identity", "SeriesNumber",
|
||||
"String", Writable: false, IsArray: false, ArrayLength: null),
|
||||
};
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables);
|
||||
Should.NotThrow(() => applier.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
|
||||
|
||||
// Each pass re-issues the same parent-first ensures (the real sink dedups via early-return); the
|
||||
// DISTINCT set the applier produces is stable across re-applies.
|
||||
sink.FolderCalls.Select(f => f.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS", "EQ-1/FOCAS/Identity" });
|
||||
sink.VariableCalls.Select(v => v.NodeId).Distinct().ShouldBe(new[] { "EQ-1/FOCAS/Identity/SeriesNumber" });
|
||||
// One model-change per call ⇒ two across two calls.
|
||||
sink.ModelChangeCalls.ShouldBe(new[] { "EQ-1", "EQ-1" });
|
||||
}
|
||||
|
||||
/// <summary>Task 4 — empty input (no folders, no variables) returns WITHOUT touching the sink: no
|
||||
/// EnsureFolder/EnsureVariable and, crucially, NO NodeAdded model-change.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseDiscoveredNodes_empty_input_does_not_touch_sink()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
applier.MaterialiseDiscoveredNodes("EQ-1", Array.Empty<DiscoveredFolder>(), Array.Empty<DiscoveredVariable>());
|
||||
|
||||
sink.FolderCalls.ShouldBeEmpty();
|
||||
sink.VariableCalls.ShouldBeEmpty();
|
||||
sink.ModelChangeCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CapturingAddressSpaceBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Records_nested_path_segments_full_reference_and_metadata()
|
||||
{
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
var focas = b.Folder("FOCAS", "FOCAS");
|
||||
var device = focas.Folder("10.0.0.5:8193", "cnc");
|
||||
var identity = device.Folder("Identity", "Identity");
|
||||
identity.Variable("SeriesNumber", "SeriesNumber", new DriverAttributeInfo(
|
||||
FullName: "10.0.0.5:8193/Identity/SeriesNumber",
|
||||
DriverDataType: DriverDataType.String, IsArray: false, ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false));
|
||||
|
||||
b.Nodes.Count.ShouldBe(1);
|
||||
var n = b.Nodes[0];
|
||||
n.FolderPathSegments.ShouldBe(new[] { "FOCAS", "10.0.0.5:8193", "Identity" });
|
||||
n.BrowseName.ShouldBe("SeriesNumber");
|
||||
n.FullReference.ShouldBe("10.0.0.5:8193/Identity/SeriesNumber");
|
||||
n.DataType.ShouldBe(DriverDataType.String);
|
||||
n.Writable.ShouldBeFalse(); // ViewOnly -> read-only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddProperty_is_ignored_and_alarm_marking_is_a_noop_sink()
|
||||
{
|
||||
var b = new CapturingAddressSpaceBuilder();
|
||||
var f = b.Folder("FOCAS", "FOCAS");
|
||||
f.AddProperty("Manufacturer", DriverDataType.String, "FANUC"); // ignored, no throw
|
||||
var h = f.Variable("V", "V", new DriverAttributeInfo("ref", DriverDataType.Int32, false, null,
|
||||
SecurityClassification.ViewOnly, false, IsAlarm: true));
|
||||
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("src", AlarmSeverity.Low, null));
|
||||
sink.ShouldNotBeNull(); // no-op sink, alarms out of scope
|
||||
b.Nodes.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,11 @@ internal static class DarkAddressSpaceReasons
|
||||
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
|
||||
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
|
||||
|
||||
/// <summary>Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's
|
||||
/// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
|
||||
/// raw tags are authored explicitly via the Batch-2 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
|
||||
/// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
|
||||
/// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
|
||||
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
|
||||
public const string DiscoveryInjectionDormantV3 =
|
||||
"v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " +
|
||||
"v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " +
|
||||
"browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " +
|
||||
"re-migrating injection onto the raw device subtree is a separate follow-up.";
|
||||
// DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18
|
||||
// tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft
|
||||
// (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped.
|
||||
// Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a
|
||||
// re-browse prompt instead (DriverInstanceActorRediscoverySignalTests).
|
||||
|
||||
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
|
||||
public const string EquipmentDeviceBindingRetired =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user