Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77c8a3569 | |||
| 5184a2e107 | |||
| 88ce8df099 | |||
| 6a01358b9a | |||
| d32d89c340 | |||
| adce27e7fa | |||
| 09a401b881 | |||
| 53ede679c3 |
@@ -155,17 +155,69 @@ Galaxy `mx_data_type` values map to OPC UA types (Boolean, Int32, Float, Double,
|
|||||||
|
|
||||||
`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.
|
`DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) polls the gateway's deploy-event signal and raises `IRediscoverable.OnRediscoveryNeeded` when the Galaxy redeploys.
|
||||||
|
|
||||||
⚠️ **Nothing consumes that signal.** No type under `src/Server/` or `src/Core/` subscribes to
|
**The signal is consumed as an operator prompt, not as an address-space rebuild** (§8.2, 2026-07-27).
|
||||||
`OnRediscoveryNeeded` — the only `+=` handlers in `src/` are Galaxy re-raising its own watcher's event
|
`DriverInstanceActor` subscribes in `PreStart` and unsubscribes in `PostStop` — the detach is
|
||||||
onto its own interface (`GalaxyDriver.cs:586`). `DriverHostActor.HandleDiscoveredNodes`
|
load-bearing, because the `IDriver` instance outlives the actor when the host respawns a child around
|
||||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:986-1002`) short-circuits with an
|
the same driver object. A raise is recorded and rides the driver-health snapshot
|
||||||
unconditional `return;` — discovered-node injection is **dormant in v3** because raw tags are authored
|
(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`) to the AdminUI `/hosts` page as a
|
||||||
through the `/raw` browse-commit flow instead. A Galaxy redeploy therefore does **not** change the
|
**"re-browse" chip**.
|
||||||
served address space; a config redeploy is required. The same inert-signal gap affects TwinCAT,
|
|
||||||
MQTT/Sparkplug and MTConnect, and `IHostConnectivityProbe` is likewise unconsumed (`GetHostStatuses()`
|
⚠️ **It is advisory: a Galaxy redeploy still does NOT change the served address space.** v3 authors raw
|
||||||
has no production call site; nothing ever writes a `DriverHostStatus` row). Tracked as Gitea **#518**
|
tags explicitly through the `/raw` browse-commit flow, so an operator must re-browse the device and
|
||||||
(no consumer) and **#507** (re-migrate injection onto the raw subtree) — note that fixing either alone
|
commit. Injecting nodes at runtime would materialise nodes nobody approved and no deployment artifact
|
||||||
changes nothing observable.
|
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
|
## mxaccessgw
|
||||||
|
|
||||||
|
|||||||
@@ -9,78 +9,110 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1,
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": [0]
|
"blockedBy": [
|
||||||
|
0
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4",
|
"subject": "RED: poll-loop-survival test T2 (subscription survives connect-timeout outage) + caller-cancel pinning test T4",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"blockedBy": [0, 1]
|
"blockedBy": [
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)",
|
"subject": "GREEN: EnsureConnectedAsync + InitializeAsync connect-timeout OCE -> TimeoutException conversion (STAB-14 root fix)",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"blockedBy": [1, 2]
|
"blockedBy": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)",
|
"subject": "Defensive when-filters on ensure-wrapper OCE rethrows (:488/:1000) + poll-loop OCE catches (:1383/:1396/:1404)",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"blockedBy": [3]
|
"blockedBy": [
|
||||||
|
3
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green",
|
"subject": "Finding-1 close gate: full S7 unit + CLI test-project sweep green",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"blockedBy": [4]
|
"blockedBy": [
|
||||||
|
4
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": [0]
|
"blockedBy": [
|
||||||
|
0
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
"subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)",
|
"subject": "GREEN: broaden IsS7ConnectionFatal to the ISO-on-TCP framing surface (NOT InvalidDataException) + xmldoc (STAB-15a)",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"blockedBy": [6]
|
"blockedBy": [
|
||||||
|
6
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)",
|
"subject": "RED->GREEN: cancellation-during-I/O marks handle dead (T6 read + T7 write; per-item OCE catches set _plcDead before propagating)",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"blockedBy": [7]
|
"blockedBy": [
|
||||||
|
7
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"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)",
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": [8]
|
"blockedBy": [
|
||||||
|
8
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync <remarks> (STAB-8 S7 part, note-only)",
|
"subject": "Doc: R2-09 connect-throttle seam note on EnsureConnectedAsync <remarks> (STAB-8 S7 part, note-only)",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"blockedBy": [3]
|
"blockedBy": [
|
||||||
|
3
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"subject": "Env-gated live connect-timeout outage test (S7_TIMEOUT_OUTAGE_START_CMD/STOP_CMD blackhole design; skips cleanly offline)",
|
"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.",
|
"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,
|
"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)",
|
"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",
|
"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).",
|
"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",
|
"planPath": "archreview/plans/R2-02-resilience-config-hardening-plan.md",
|
||||||
"tasks": [
|
"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": 0,
|
||||||
{ "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] },
|
"subject": "S-6 brick-repro test (RED): parsed timeoutSeconds:0 / breakerFailureThreshold:1 must not brick capability calls \u2014 ResilienceConfigBrickTests, expect 2 failures on f6eaa267",
|
||||||
{ "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] },
|
"status": "completed",
|
||||||
{ "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] },
|
"blockedBy": []
|
||||||
{ "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": 1,
|
||||||
{ "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] },
|
"subject": "Add ResiliencePolicyRanges constants to Core.Abstractions (single source of truth for parser/builder/AdminUI, derived from Polly.Core 8.6.6 validation)",
|
||||||
{ "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] },
|
"status": "completed",
|
||||||
{ "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] },
|
"blockedBy": []
|
||||||
{ "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": 2,
|
||||||
{ "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] },
|
"subject": "S-6 parser: clamp timeout/retry/breaker overrides with appending diagnostics (timeout<=0 -> tier default; breaker==1 -> 2; caps) + 8 parser tests",
|
||||||
{ "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] },
|
"status": "completed",
|
||||||
{ "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] },
|
"blockedBy": [
|
||||||
{ "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] },
|
0,
|
||||||
{ "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] }
|
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": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"id": "R2-03-T1",
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "R2-03-T9",
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"R2-03-T3"
|
"R2-03-T3"
|
||||||
@@ -86,7 +86,8 @@
|
|||||||
"R2-03-T8",
|
"R2-03-T8",
|
||||||
"R2-03-T10"
|
"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",
|
"id": "R2-03-T12",
|
||||||
@@ -95,8 +96,9 @@
|
|||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"R2-03-T11"
|
"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",
|
"planPath": "archreview/plans/R2-05-adminui-authz-plan.md",
|
||||||
"lastUpdated": "2026-07-12",
|
"lastUpdated": "2026-07-27",
|
||||||
"tasks": [
|
"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": "T1",
|
||||||
{ "id": "T3", "subject": "Reflection PageAuthorizationGuardTests (RED: must list ~35 ungated/mis-idiom pages)", "status": "completed", "blockedBy": ["T1"] },
|
"subject": "Add AdminUiPolicies constants class (Security/Auth/AdminUiPolicies.cs)",
|
||||||
{ "id": "T4", "subject": "Register ConfigEditor + AuthenticatedRead policies; switch existing AddPolicy literals to constants (GREEN for T2)", "status": "completed", "blockedBy": ["T2"] },
|
"status": "completed",
|
||||||
{ "id": "T5", "subject": "_Imports.razor usings (Authorization + Security.Auth) + de-dupe Deployments/Alerts in-file @using", "status": "completed", "blockedBy": ["T1"] },
|
"blockedBy": []
|
||||||
{ "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": "T2",
|
||||||
{ "id": "T9", "subject": "Converge Deployments/Scripts/ScriptEdit from Roles-string to ConfigEditor policy", "status": "completed", "blockedBy": ["T4", "T5"] },
|
"subject": "Policy-semantics unit test AdminUiPoliciesTests (RED: ConfigEditor/AuthenticatedRead unregistered)",
|
||||||
{ "id": "T10", "subject": "Read pages to AuthenticatedRead (16 incl. Home insert) + RoleGrants to FleetAdmin constant", "status": "completed", "blockedBy": ["T4", "T5"] },
|
"status": "completed",
|
||||||
{ "id": "T11", "subject": "Guard GREEN checkpoint + full AdminUI.Tests; commit the gate", "status": "completed", "blockedBy": ["T3", "T6", "T7", "T8", "T9", "T10"] },
|
"blockedBy": [
|
||||||
{ "id": "T12", "subject": "Converge non-page literals to constants (ScriptAnalysisEndpoints, imperative DriverOperator x4, Certificates FleetAdmin x2)", "status": "completed", "blockedBy": ["T11"] },
|
"T1"
|
||||||
{ "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": "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",
|
"planPath": "archreview/plans/R2-06-serverhistorian-failfast-plan.md",
|
||||||
"lastUpdated": "2026-07-12",
|
"lastUpdated": "2026-07-27",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"id": "T1",
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T2",
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T1"
|
"T1"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "T7",
|
"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",
|
"status": "completed",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
@@ -89,12 +89,13 @@
|
|||||||
{
|
{
|
||||||
"id": "T12",
|
"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",
|
"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": [
|
"blockedBy": [
|
||||||
"T9",
|
"T9",
|
||||||
"T11"
|
"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",
|
"planPath": "archreview/plans/R2-07-surgical-pure-adds-plan.md",
|
||||||
"lastUpdated": "2026-07-12",
|
"lastUpdated": "2026-07-27",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"id": "T1",
|
"id": "T1",
|
||||||
@@ -45,20 +45,22 @@
|
|||||||
{
|
{
|
||||||
"id": "T5",
|
"id": "T5",
|
||||||
"subject": "Phase 1: over-the-wire SubscriptionSurvivalTests \u2014 subscribe, pure-add, item survives + new node browsable",
|
"subject": "Phase 1: over-the-wire SubscriptionSurvivalTests \u2014 subscribe, pure-add, item survives + new node browsable",
|
||||||
"status": "deferred-live",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
"T4b"
|
"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",
|
"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",
|
"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": [
|
"blockedBy": [
|
||||||
"T5"
|
"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",
|
"id": "T7",
|
||||||
@@ -104,11 +106,12 @@
|
|||||||
{
|
{
|
||||||
"id": "T12",
|
"id": "T12",
|
||||||
"subject": "Phase 2: remove-side subscription-survival integration test + live-/run remove gate (kind=PureRemove rebuild=False) \u2014 Phase 2 shippable boundary",
|
"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": [
|
"blockedBy": [
|
||||||
"T11"
|
"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",
|
"id": "T13",
|
||||||
@@ -121,11 +124,12 @@
|
|||||||
{
|
{
|
||||||
"id": "T14",
|
"id": "T14",
|
||||||
"subject": "Phase 3: mixed-deploy integration test + live-/run mixed gate + STATUS.md/P1 close-out \u2014 plan complete",
|
"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": [
|
"blockedBy": [
|
||||||
"T13"
|
"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",
|
"planPath": "archreview/plans/R2-10-resilience-observability-plan.md",
|
||||||
"lastUpdated": "2026-07-12",
|
"lastUpdated": "2026-07-27",
|
||||||
"tasks": [
|
"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": "T1",
|
||||||
{ "id": "T3", "subject": "Invoker: successful Execute* resets ConsecutiveFailures (TDD in CapabilityInvokerTests)", "status": "completed", "blockedBy": ["T1"] },
|
"subject": "Tracker: LastBreakerClosedUtc + IsBreakerOpen + RecordBreakerClose (Core, TDD in DriverResilienceStatusTrackerTests)",
|
||||||
{ "id": "T4", "subject": "Commons: DriverResilienceStatusChanged record + driver-resilience-status TopicName", "status": "completed", "blockedBy": [] },
|
"status": "completed",
|
||||||
{ "id": "T5", "subject": "Host: DriverResilienceStatusPublisherService (BuildMessages mapping + 5s DPS timer shell; TDD mapping in Host.IntegrationTests)", "status": "completed", "blockedBy": ["T1", "T4"] },
|
"blockedBy": []
|
||||||
{ "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": "T2",
|
||||||
{ "id": "T9", "subject": "AdminUI: resilience chip section in DriverStatusPanel.razor (in-process store read; no bUnit — verified by T10)", "status": "completed", "blockedBy": ["T7"] },
|
"subject": "Builder: OnClosed records breaker-close on the tracker (TDD in DriverResiliencePipelineBuilderTests)",
|
||||||
{ "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"] },
|
"status": "completed",
|
||||||
{ "id": "T11", "subject": "Docs + review ledger: mark 03/U10 + 04/U-5 remediated; update STATUS.md + FOLLOWUP-10", "status": "completed", "blockedBy": ["T10"] }
|
"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",
|
"planPath": "archreview/plans/R2-11-tagconfig-consolidation-plan.md",
|
||||||
"lastUpdated": "2026-07-12",
|
"lastUpdated": "2026-07-27",
|
||||||
"tasks": [
|
"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": "T1",
|
||||||
{ "id": "T3", "subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)", "status": "completed", "blockedBy": [] },
|
"subject": "Golden TagConfig corpus + compose\u2192encode\u2192decode parity characterization test (Runtime.Tests, green on unmodified tree)",
|
||||||
{ "id": "T4", "subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)", "status": "completed", "blockedBy": [] },
|
"status": "completed",
|
||||||
{ "id": "T5", "subject": "AddressSpaceComposer swap: single TagConfigIntent.Parse per tag, delete 4 Extract* statics (P-1 parse-once)", "status": "completed", "blockedBy": ["T1", "T3"] },
|
"blockedBy": []
|
||||||
{ "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": "T2",
|
||||||
{ "id": "T9", "subject": "EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint tree-wide canonical-parser doc cites", "status": "completed", "blockedBy": ["T2", "T3"] },
|
"subject": "FullName-semantics characterization for EquipmentNodeWalker (Core.Tests) + DraftValidator Galaxy rule (Configuration.Tests)",
|
||||||
{ "id": "T10", "subject": "Retire OpcUaServer.Tests ExtractTag*Tests (authority moved to Commons.Tests); verify zero 'MUST parse identically' hits", "status": "completed", "blockedBy": ["T3", "T5"] },
|
"status": "completed",
|
||||||
{ "id": "T11", "subject": "TagConfigJson strict-capable readers in Core.Abstractions (TryReadEnum Absent/Valid/Invalid, ReadEnumOrDefault, ReadWritable, DescribeInvalidEnum) (TDD)", "status": "completed", "blockedBy": [] },
|
"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": "T3",
|
||||||
{ "id": "T15", "subject": "AbLegacy equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
"subject": "TagConfigIntent + TagAlarmIntent in Commons/Types (TDD; port composer semantics verbatim + OpcUaServer ExtractTag* test tables)",
|
||||||
{ "id": "T16", "subject": "TwinCAT equipment-tag parser: freeze test, shared readers, honour writable key, Inspect warnings", "status": "completed", "blockedBy": ["T11"] },
|
"status": "completed",
|
||||||
{ "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"] },
|
"blockedBy": []
|
||||||
{ "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": "T4",
|
||||||
{ "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] },
|
"subject": "DeviceConfigIntent (TryExtractHost/NormalizeHost) in Commons/Types (TDD)",
|
||||||
{ "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "deferred-live", "blockedBy": [] },
|
"status": "completed",
|
||||||
{ "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"] },
|
"blockedBy": []
|
||||||
{ "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": "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'."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+317
-11
@@ -77,7 +77,10 @@ Every gap below is in the **authoring/dispatch layer, not driver runtime code**.
|
|||||||
- `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.
|
- `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".
|
- `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`.
|
- `CsvColumnMap.cs:322-324` — hardcodes `CalculationDriverType = "Calculation"` "Not yet a `DriverTypeNames` constant"; it has been one since `DriverTypeNames.cs:54`.
|
||||||
- `SqlDriver.cs:218-220` — justifies not re-parsing config on the premise "the factory builds a fresh instance", **disproved** by `DriverInstanceActor.cs:316` + `DriverHostActor.cs:2565`. Should be corrected alongside #516.
|
- ~~`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -342,17 +345,320 @@ appear in §7.1 as well.
|
|||||||
|
|
||||||
## 8. Recommended sequencing
|
## 8. Recommended sequencing
|
||||||
|
|
||||||
1. **§3.1 ACL enforcement** — decide: wire `IPermissionEvaluator` into `OtOpcUaNodeManager`, or
|
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
|
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.
|
`docs/ReadWriteOperations.md` first; it is the one that could mislead a security review.~~
|
||||||
2. **#518 + #507 together** — neither fix is observable alone.
|
✅ **Done** — decided *make non-enforcement explicit*; wire-up tracked as Gitea **#520**. See §9.
|
||||||
3. **#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.
|
(`docs/ReadWriteOperations.md` was **not** the sharpest one — `docs/security.md` was.)
|
||||||
4. **G-4** — extend the existing picker-parity test to `DriverConfigModal` + `DeviceModal`; it would
|
2. ~~**#518 + #507 together** — neither fix is observable alone.~~ ✅ **Done.** The framing was right
|
||||||
have caught G-1 and G-2 for free, and this class has now recurred twice.
|
that they had to be handled together, but wrong about the resolution: **#507 was not revivable**,
|
||||||
5. **Bookkeeping sweep** — mark the 8 stale `.tasks.json` files complete so the 19-task AdminUI plan
|
so it was deleted rather than fixed. See §9.
|
||||||
(§6.2) is visible as the real backlog it is.
|
3. ~~**#516** — silent config discard on 5 drivers; then re-scope #489, which it partly subsumes.~~
|
||||||
6. **§3.3 tier truth** — either pass real tiers at factory registration or delete the Tier-C
|
✅ **Done.** See §9. #489 is still open and should now be re-scoped.
|
||||||
machinery; today it is documented, authorable and dormant.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+96
-61
@@ -1,85 +1,120 @@
|
|||||||
# Read/Write Operations
|
# Read/Write Operations
|
||||||
|
|
||||||
> ⚠️ **Accuracy warning (audited 2026-07-27).** Parts of this page describe v2-era machinery that no
|
> **Rewritten 2026-07-27** against `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`.
|
||||||
> longer exists. Two corrections matter most:
|
> 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
|
||||||
> 1. **There is no per-node ACL gate on the read path — or anywhere else.** `WriteAuthzPolicy`,
|
> those exist** — `OnReadValue`, `WriteAuthzPolicy`, `_sourceByFullRef`, `_writeIdempotentByFullRef`
|
||||||
> `AuthorizationGate`, `NodeScopeResolver` and `AuthorizationBootstrap` have **zero occurrences in
|
> and `IRoleBearer` all have zero occurrences in `src/`. The read path in particular worked nothing
|
||||||
> `src/`**. The ACL evaluator that *does* exist (`IPermissionEvaluator` / `TriePermissionEvaluator`
|
> like the way it was documented. See `deferment.md` §3.1 and §7.
|
||||||
> / `PermissionTrieCache`, `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/`) has **no production
|
|
||||||
> consumer** — `OtOpcUaNodeManager` never references it — even though `ClusterAcls.razor` lets
|
|
||||||
> operators author `NodeAcl` rows and `ConfigComposer.cs:51` ships them in every deployment
|
|
||||||
> artifact. Actual enforcement today is LDAP role mapping plus the realm-qualified `WriteOperate`
|
|
||||||
> check in `OtOpcUaNodeManager`, which is a **write** gate; **reads are ungated**. See
|
|
||||||
> `deferment.md` §3.1.
|
|
||||||
> 2. **`GenericDriverNodeManager` is not a production dispatch path** — it is Core test scaffolding
|
|
||||||
> with zero production references (`GenericDriverNodeManager.cs:71`). The live server materialises
|
|
||||||
> the address space through `AddressSpaceComposer` / `AddressSpaceApplier`.
|
|
||||||
>
|
|
||||||
> The `CapabilityInvoker` / Polly / `OnReadValue` / `OnWriteValue` mechanics below remain accurate.
|
|
||||||
|
|
||||||
The v2 server routes OPC UA Read and Write operations to each driver's `IReadable` and `IWritable` capabilities through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers. The per-variable `OnReadValue` and `OnWriteValue` hooks described in the sections below live in `DriverNodeManager` (the planned ADR-002 Phase 7 Stream G successor to the v1 `DriverNodeManager`); `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) handles address-space population and alarm routing during discovery. The current `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`) is a push-model `CustomNodeManager2` that receives values from the Akka actor layer via `WriteValue`; OPC UA client reads return the cached pushed value.
|
## The shape of it: push for reads, pull for writes
|
||||||
|
|
||||||
## Driver vs virtual dispatch
|
The live server is `OtOpcUaNodeManager`, a **push-model** `CustomNodeManager2`. This asymmetry is the
|
||||||
|
single most important thing on this page:
|
||||||
|
|
||||||
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:
|
- **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.
|
||||||
|
|
||||||
- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc).
|
Everything the old page said about `CapabilityInvoker` wrapping OPC UA reads was misplaced: the
|
||||||
- `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.
|
invoker is real, but it lives on the **driver-actor** side wrapping the poll/subscribe calls. The
|
||||||
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim.
|
`OpcUaServer` project does not reference it at all.
|
||||||
|
|
||||||
~~ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.~~ **Not true** — neither type exists; there is no per-node ACL gate. The only authorization applied before the source branch is the LDAP-role write gate (`WriteOperate` / `WriteTune` / `WriteConfigure`, realm-qualified, fail-closed). See the banner at the top of this page.
|
## Read path
|
||||||
|
|
||||||
## OnReadValue
|
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.
|
||||||
|
|
||||||
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:
|
Authored `NodeAcl` deny rules have **no effect on reads** (or on anything else — see
|
||||||
|
[docs/security.md](security.md) § Data-Plane Authorization).
|
||||||
|
|
||||||
1. If the driver does not implement `IReadable`, the hook returns `BadNotReadable`.
|
## Write path — `OnEquipmentTagWrite`
|
||||||
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.~~ **Never shipped** — neither type exists in `src/`, and no ACL gate is consulted on the read path. Reads are authorized only by the `AccessLevels` bits set at materialization (and `HistoryRead` for history). Authored `NodeAcl` deny rules have **no effect on reads**.
|
|
||||||
4. The call is wrapped by `_invoker.ExecuteAsync(DriverCapability.Read, ResolveHostFor(fullRef), …)`. The resolved host is `IPerCallHostResolver.ResolveHost(fullRef)` for multi-host drivers; single-host drivers fall back to `DriverInstanceId` (decision #144).
|
|
||||||
5. The first `DataValueSnapshot` from the batch populates the outgoing `value` / `statusCode` / `timestamp`. An empty batch surfaces `BadNoData`; any exception surfaces `BadInternalError`.
|
|
||||||
|
|
||||||
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.
|
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
|
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.)
|
||||||
|
|
||||||
`OnWriteValue` follows the same shape with two additional concerns: authorization and idempotence.
|
`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.
|
||||||
|
|
||||||
### Authorization (two layers)
|
## Alarm method calls
|
||||||
|
|
||||||
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`).
|
Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve)
|
||||||
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`.
|
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
|
||||||
### Dispatch
|
through `HandleAlarmCommand`, driver-fed native alarms through `HandleNativeAlarmAck`; both fail closed
|
||||||
|
with `BadUserAccessDenied`.
|
||||||
`_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.
|
|
||||||
|
|
||||||
## HistoryRead
|
## 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
|
## 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
|
## 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` — the live node manager;
|
||||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` — push-model `CustomNodeManager2`; `EnsureVariable` / `WriteValue` are the v2 read/write path
|
`EnsureVariable` / `WriteValue` / `OnEquipmentTagWrite` / `EvaluateEquipmentWriteGate` and the four
|
||||||
- `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
|
HistoryRead overrides
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs` — `ExecuteAsync` / `ExecuteWriteAsync`
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs`,
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs`, `IWritable.cs`, `WriteIdempotentAttribute.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 |
|
| Capability | Implementation entry point |
|
||||||
|------------|---------------------------|
|
|------------|---------------------------|
|
||||||
| `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` |
|
| `ITagDiscovery` | `Browse/GalaxyDiscoverer.cs` |
|
||||||
| `IRediscoverable` | `Browse/DeployWatcher.cs` — ⚠️ raised but **unconsumed**; a Galaxy redeploy does not rebuild the address space ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518), [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)) |
|
| `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` |
|
| `IReadable` | `Runtime/GalaxyMxSession.cs` |
|
||||||
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
|
| `IWritable` | `Runtime/GatewayGalaxyDataWriter.cs` |
|
||||||
| `ISubscribable` | `Runtime/GatewayGalaxySubscriber.cs` (driven by `EventPump`) |
|
| `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
|
**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
|
the two need disambiguating, not just OR-ing together). On a changed
|
||||||
`instanceId` the driver clears its cached probe model and raises
|
`instanceId` the driver clears its cached probe model and raises
|
||||||
`OnRediscoveryNeeded` — see [Known limitations](#known-limitations) for why
|
`OnRediscoveryNeeded`, which surfaces a **re-browse prompt** on the AdminUI
|
||||||
that signal currently has no consumer.
|
`/hosts` page — see [Known limitations](#known-limitations) for what that does
|
||||||
|
and does not do.
|
||||||
|
|
||||||
## Browse — free via the universal browser
|
## 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
|
These are real, not placeholders — read them before relying on the driver
|
||||||
for anything beyond values-and-conditions.
|
for anything beyond values-and-conditions.
|
||||||
|
|
||||||
1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the
|
1. **A restarted Agent prompts an operator; it does not re-shape the address
|
||||||
server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and
|
space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded`
|
||||||
`IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to
|
(2026-07-27), so a changed `instanceId` raises a "re-browse" chip against
|
||||||
`OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver`
|
this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors
|
||||||
wiring its own internal `DeployWatcher` sub-component. So a restarted
|
raw tags through the `/raw` browse-commit flow, so until an operator
|
||||||
Agent (a changed `instanceId`) leaves a stale address space behind an
|
re-browses the Agent and commits, any DataItem that appeared or vanished is
|
||||||
otherwise-Healthy driver. **This is a pre-existing fleet-wide gap
|
not reflected in the served tree. A runtime graft was deliberately rejected
|
||||||
affecting every driver that implements either interface** (eight drivers
|
— it would materialise nodes nobody approved and no deployment artifact
|
||||||
besides Galaxy), not something specific to MTConnect — this build simply
|
records.
|
||||||
surfaced it again.
|
|
||||||
|
**`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
|
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
|
||||||
routing.** One driver instance owns exactly one Agent client scoped by
|
routing.** One driver instance owns exactly one Agent client scoped by
|
||||||
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
|
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ fixture is down during normal dev.
|
|||||||
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
|
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
|
||||||
|
|
||||||
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
|
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
|
||||||
arrays, per-element historization. `IRediscoverable` still fires (but is unconsumed — #518)
|
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).
|
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
|
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",
|
"planPath": "docs/plans/2026-06-12-historian-tcp-transport.md",
|
||||||
"tasks": [
|
"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": 0,
|
||||||
{"id": 2, "nativeTaskId": 298, "subject": "Task 2: Client TCP connect factory + FrameChannel rename", "status": "pending", "blockedBy": [1]},
|
"nativeTaskId": 296,
|
||||||
{"id": 3, "nativeTaskId": 299, "subject": "Task 3: Switch client default ctor to TCP", "status": "pending", "blockedBy": [2]},
|
"subject": "Task 0: Create feature branch",
|
||||||
{"id": 4, "nativeTaskId": 300, "subject": "Task 4: Host config binding (Host/Port/TLS)", "status": "pending", "blockedBy": [1]},
|
"status": "completed"
|
||||||
{"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": 1,
|
||||||
{"id": 8, "nativeTaskId": 304, "subject": "Task 8: Deploy scripts — env block + firewall + cert", "status": "pending", "blockedBy": [7]},
|
"nativeTaskId": 297,
|
||||||
{"id": 9, "nativeTaskId": 305, "subject": "Task 9: AdminUI Test-Connect probe to host/port/TLS", "status": "pending", "blockedBy": [7]},
|
"subject": "Task 1: Add TCP/TLS fields to client options",
|
||||||
{"id": 10, "nativeTaskId": 306, "subject": "Task 10: Docs — TCP transport", "status": "pending", "blockedBy": [7]},
|
"status": "completed",
|
||||||
{"id": 11, "nativeTaskId": 307, "subject": "Task 11: Verification (build + test + live)", "status": "pending", "blockedBy": [8, 9, 10]}
|
"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",
|
"planPath": "docs/plans/2026-06-15-stillpending-phase-0-1.md",
|
||||||
"tasks": [
|
"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": 402,
|
||||||
{"id": 404, "subject": "SP Task 2: Phase 0 — fix docs/security.md + benign-residue comments", "status": "pending", "blockedBy": [402]},
|
"subject": "SP Task 0: Feature branch feat/stillpending-phase-0-1",
|
||||||
{"id": 405, "subject": "SP Task 3: Phase 0 — mark shipped .tasks.json completed", "status": "pending", "blockedBy": [402]},
|
"status": "completed"
|
||||||
{"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": 403,
|
||||||
{"id": 409, "subject": "SP Task 7: Phase 1 H5b — DeploymentArtifact decodes Historize (byte-parity)", "status": "pending", "blockedBy": [408]},
|
"subject": "SP Task 1: Phase 0 \u2014 correct 7 stale code comments",
|
||||||
{"id": 410, "subject": "SP Task 8: Phase 1 H5c — VirtualTagHostActor invokes IHistoryWriter", "status": "pending", "blockedBy": [407, 409]},
|
"status": "completed",
|
||||||
{"id": 411, "subject": "SP Task 9: Phase 1 H5d — thread IHistoryWriter through DriverHostActor + DI", "status": "pending", "blockedBy": [410, 403]},
|
"blockedBy": [
|
||||||
{"id": 412, "subject": "SP Task 10: Phase 1 — docs + follow-up bookkeeping", "status": "pending", "blockedBy": [411]},
|
402
|
||||||
{"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": 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",
|
"planPath": "docs/plans/2026-06-15-stillpending-phase-2-servicelevel.md",
|
||||||
"branch": "feat/stillpending-phase-2-servicelevel",
|
"branch": "feat/stillpending-phase-2-servicelevel",
|
||||||
"tasks": [
|
"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": 414,
|
||||||
{"id": 416, "subject": "P2 Task 2b: OpcUaProbeOk from peer-probes-me (freshness + debounce)", "status": "pending", "blockedBy": [415]},
|
"subject": "P2 Task 1: Move ServiceLevelCalculator to Core.Cluster",
|
||||||
{"id": 417, "subject": "P2 Task 3: HealthTick — periodic DB Ask/PipeTo + PreStart immediate refresh", "status": "pending", "blockedBy": [416]},
|
"status": "completed"
|
||||||
{"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": 415,
|
||||||
{"id": 421, "subject": "P2 Task 7: Full build + test + final integration review", "status": "pending", "blockedBy": [419, 420]},
|
"subject": "P2 Task 2a: OpcUaPublishActor calculator path (DB+stale+leader+Detached guard, legacy seam)",
|
||||||
{"id": 422, "subject": "P2 Task 8: Live /run on the 2-node rig (acceptance gate)", "status": "pending", "blockedBy": [421]}
|
"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",
|
"planPath": "docs/plans/2026-06-15-stillpending-phase-3-opcua-standards.md",
|
||||||
"branch": "feat/stillpending-phase-3-opcua-standards",
|
"branch": "feat/stillpending-phase-3-opcua-standards",
|
||||||
"tasks": [
|
"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": 423,
|
||||||
{"id": 425, "subject": "P3 Task 3: H4 — wire OnEnableDisable over OPC UA", "status": "pending", "blockedBy": [424]},
|
"subject": "P3 Task 1: H2-bit \u2014 NodePermissions.HistoryUpdate + evaluator mapping",
|
||||||
{"id": 426, "subject": "P3 Task 4: H6b — AlarmAcknowledgeRequest.OperatorUser + Galaxy/ScriptedAlarmSource", "status": "pending"},
|
"status": "completed"
|
||||||
{"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": 424,
|
||||||
{"id": 430, "subject": "P3 Task 8: Docs — Enable/Disable + native-ack→AVEVA + HistoryUpdate bit", "status": "pending"},
|
"subject": "P3 Task 2: H6a \u2014 mark native conditions (isNative through the sink)",
|
||||||
{"id": 431, "subject": "P3 Task 9: Full build + test + final integration review", "status": "pending", "blockedBy": [423, 425, 429, 430]},
|
"status": "completed"
|
||||||
{"id": 432, "subject": "P3 Task 10: Live /run — H4 Enable/Disable + H6 native-ack route", "status": "pending", "blockedBy": [431]}
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"planPath": "docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes.md",
|
||||||
"branch": "feat/stillpending-phase-4-driver-datatypes",
|
"branch": "feat/stillpending-phase-4-driver-datatypes",
|
||||||
"tasks": [
|
"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": 433,
|
||||||
{"id": 435, "subject": "P4 Task 3: FOCAS position scaling (PositionDecimalPlaces)", "status": "pending", "blockedBy": [434]},
|
"subject": "P4 Task 1: Modbus Int64/UInt64 node DataType (MapDataType split)",
|
||||||
{"id": 436, "subject": "P4 Task 4: Historian Total aggregate (client-side Average x interval-seconds)", "status": "pending"},
|
"status": "completed"
|
||||||
{"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": 434,
|
||||||
{"id": 440, "subject": "P4 Task 8: Live /run — Modbus Int64 (acceptance gate)", "status": "pending", "blockedBy": [439]}
|
"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",
|
"designCommit": "f90017bc",
|
||||||
"baseMaster": "c081917a",
|
"baseMaster": "c081917a",
|
||||||
"branch": "feat/stillpending-phase-4b-driver-gaps (merged to master 08a65513, deleted)",
|
"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).",
|
"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},
|
"nativeTaskIds": {
|
||||||
|
"1": 482,
|
||||||
|
"2": 483,
|
||||||
|
"3a": 484,
|
||||||
|
"3b": 485,
|
||||||
|
"4": 486,
|
||||||
|
"5": 487,
|
||||||
|
"6": 488
|
||||||
|
},
|
||||||
"tasks": [
|
"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": 1,
|
||||||
{"id": "3a", "subject": "Task 3a: IFocasClient.GetPositionFiguresAsync (cnc_getfigure binding)", "status": "completed", "commit": "3fcbc70c"},
|
"subject": "Task 1: Modbus driver-type-string reconcile (canonicalize on \"Modbus\")",
|
||||||
{"id": "3b", "subject": "Task 3b: FOCAS driver auto-scale wiring (auto wins, manual fallback)", "status": "completed", "commit": "6855be28"},
|
"status": "completed",
|
||||||
{"id": 4, "subject": "Task 4: Docs + bookkeeping", "status": "completed", "commit": "08a65513"},
|
"commit": "8b4675b1",
|
||||||
{"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"},
|
"reviewFixCommit": "a40c77de"
|
||||||
{"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": 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": [
|
"reviewFollowUps": [
|
||||||
"FOCAS minor: no upper-bound clamp on a misbehaving cnc_getfigure value (Math.Pow overflow; same latent risk the manual path had)",
|
"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)",
|
"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)"
|
"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",
|
"designCommit": "efccd8d1",
|
||||||
"baseMaster": "050164b2",
|
"baseMaster": "050164b2",
|
||||||
"branch": "feat/stillpending-phase-4c-array-support (merged to master 0f92e9e2, deleted)",
|
"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).",
|
"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": [
|
"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": 1,
|
||||||
{"id": 3, "subject": "DeploymentArtifact decode byte-parity", "classification": "high-risk", "status": "completed", "commit": "0a747c34", "reviewFixCommit": "eb8a8dc1"},
|
"subject": "Sink contract \u2014 EnsureVariable array params",
|
||||||
{"id": 4, "subject": "AdminUI driver-agnostic isArray/arrayLength control", "classification": "standard", "status": "completed", "commit": "c2006dfb"},
|
"classification": "high-risk",
|
||||||
{"id": 5, "subject": "Modbus String/BitInRegister array decode + resolver", "classification": "small", "status": "completed", "commit": "8d3dc321", "reviewFixCommit": "49ac1392"},
|
"status": "completed",
|
||||||
{"id": 6, "subject": "AbCip libplctag array read + IsArray", "classification": "standard", "status": "completed", "commit": "f4d5a5ee", "reviewFixCommit": "94e8c55b+5f7a2acd"},
|
"commit": "a7928202",
|
||||||
{"id": 7, "subject": "TwinCAT ADS array read + IsArray (reference impl, no fix needed)", "classification": "standard", "status": "completed", "commit": "3e742395"},
|
"reviewFixCommit": "3172b7bd"
|
||||||
{"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": 2,
|
||||||
{"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)"},
|
"subject": "EquipmentTagPlan IsArray/ArrayLength + composer + applier",
|
||||||
{"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)"}
|
"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": [
|
"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)",
|
"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 — add array element formatting",
|
"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)",
|
"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"
|
"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",
|
"planPath": "docs/plans/2026-06-26-otopcua-historian-gateway-integration.md",
|
||||||
"tasks": [
|
"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": 0,
|
||||||
{ "id": 2, "subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)", "status": "pending", "blockedBy": [0] },
|
"subject": "Task 1: Consume gateway packages + scaffold Gateway driver project",
|
||||||
{ "id": 3, "subject": "Task 4: HistorianSample/Aggregate->DataValueSnapshot + quality mapper", "status": "pending", "blockedBy": [0] },
|
"status": "completed",
|
||||||
{ "id": 4, "subject": "Task 5: HistorianEvent->HistoricalEvent mapper (+ severity)", "status": "pending", "blockedBy": [0] },
|
"blockedBy": []
|
||||||
{ "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": 1,
|
||||||
{ "id": 8, "subject": "Task 9: Reshape ServerHistorianOptions to gateway form", "status": "pending", "blockedBy": [0] },
|
"subject": "Task 2: HistoryAggregateType->RetrievalMode mapper (matrix-guarded)",
|
||||||
{ "id": 9, "subject": "Task 10: Swap AddServerHistorian factory in Program.cs (READ CUTOVER)", "status": "pending", "blockedBy": [6, 8] },
|
"status": "completed",
|
||||||
{ "id": 10, "subject": "Task 11: ReadEventsAsync alarm-history on the data source", "status": "pending", "blockedBy": [6, 4] },
|
"blockedBy": [
|
||||||
{ "id": 11, "subject": "Task 12: GatewayAlarmHistorianWriter (SendEvent + outcome mapping)", "status": "pending", "blockedBy": [9, 5] },
|
0
|
||||||
{ "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": 2,
|
||||||
{ "id": 16, "subject": "Task 17: ContinuousHistorizationRecorder actor", "status": "pending", "blockedBy": [15, 9] },
|
"subject": "Task 3: DriverDataType->HistorianDataType mapper + write-gap fallbacks (matrix-guarded)",
|
||||||
{ "id": 17, "subject": "Task 18: Wire recorder into DI + hosted lifecycle", "status": "pending", "blockedBy": [16] },
|
"status": "completed",
|
||||||
{ "id": 18, "subject": "Task 19: Retire Wonderware historian projects", "status": "pending", "blockedBy": [9, 12, 17, 19] },
|
"blockedBy": [
|
||||||
{ "id": 19, "subject": "Task 20: Env-gated live validation vs wonder-sql-vd03", "status": "pending", "blockedBy": [9, 10, 12, 17] },
|
0
|
||||||
{ "id": 20, "subject": "Task 21: Documentation (CLAUDE.md, appsettings, README)", "status": "pending", "blockedBy": [18] }
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"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.",
|
"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": [
|
"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": 0,
|
||||||
{"id": 2, "subject": "Phase 2: comm actors + receptionist + ClusterClient transport (deploy notify/acks, driver-control)", "status": "in_progress", "blockedBy": [1]},
|
"subject": "Prereq: execute 2026-07-22-selfform-fallback-and-manual-failover.md (7 tasks)",
|
||||||
{"id": 3, "subject": "Phase 3: config fetch-and-cache from central; LocalDb steady-state config store (live gate)", "status": "pending", "blockedBy": [2]},
|
"status": "completed",
|
||||||
{"id": 4, "subject": "Phase 4: cut driver-side ConfigDb — EfAlarmConditionStateStore to LocalDb, DbHealthProbeActor ServiceLevel input, OpcUaPublishActor audit (live gate)", "status": "pending", "blockedBy": [3]},
|
"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": 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": 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,
|
"id": 9,
|
||||||
"subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore",
|
"subject": "Task 9: Drop dead ConfigDb ScriptedAlarmState table + retire EfAlarmConditionStateStore",
|
||||||
"status": "deferred",
|
"status": "completed",
|
||||||
"blockedBy": [
|
"blockedBy": [
|
||||||
6
|
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"
|
"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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,91 @@
|
|||||||
{
|
{
|
||||||
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
|
"planPath": "docs/plans/2026-07-24-modbus-rtu-driver.md",
|
||||||
"tasks": [
|
"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": 0,
|
||||||
{"id": 2, "subject": "Task 2: ModbusRtuFraming ADU build + FC-aware length-less deframe", "status": "pending", "blockedBy": [1]},
|
"subject": "Task 0: Add the ModbusTransportMode enum (Tcp | RtuOverTcp)",
|
||||||
{"id": 3, "subject": "Task 3: Extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)", "status": "pending"},
|
"status": "completed"
|
||||||
{"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": 1,
|
||||||
{"id": 7, "subject": "Task 7: Route driver default closure + probe through ModbusTransportFactory", "status": "pending", "blockedBy": [5, 6]},
|
"subject": "Task 1: ModbusCrc CRC-16/MODBUS helper + golden vectors",
|
||||||
{"id": 8, "subject": "Task 8: AdminUI Transport selector on ModbusDriverForm", "status": "pending", "blockedBy": [6]},
|
"status": "completed"
|
||||||
{"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": 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",
|
"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": [
|
"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": 0,
|
||||||
{"id": 2, "subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
"subject": "Task 0 (P1): Dependency-validation spike \u2014 MQTTnet-5 pin + net10 restore/build under central pinning",
|
||||||
{"id": 3, "subject": "Task 3 (P1): .Driver + MqttConnection connect/TLS/auth (bounded deadline)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [1]},
|
"status": "completed",
|
||||||
{"id": 4, "subject": "Task 4 (P1): MqttConnection hand-rolled reconnect loop (backoff + resubscribe)", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [3]},
|
"classification": "standard",
|
||||||
{"id": 5, "subject": "Task 5 (P1): LastValueCache + IReadable (per-ref no-data)", "status": "pending", "classification": "small", "parallelizableWith": [6], "blockedBy": [3]},
|
"parallelizableWith": []
|
||||||
{"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": 1,
|
||||||
{"id": 9, "subject": "Task 9 (P1): Factory + DriverTypeNames.Mqtt + Host factory/probe registration", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [7, 8]},
|
"subject": "Task 1 (P1): .Contracts enums + MqttDriverOptions DTO (name-serialized)",
|
||||||
{"id": 10, "subject": "Task 10 (P1): .Browser bespoke #-observation browser (passive)", "status": "pending", "classification": "standard", "parallelizableWith": [8], "blockedBy": [2]},
|
"status": "completed",
|
||||||
{"id": 11, "subject": "Task 11 (P1): Register MqttDriverBrowser (bespoke-first for Mqtt)", "status": "pending", "classification": "trivial", "parallelizableWith": [12], "blockedBy": [10]},
|
"classification": "small",
|
||||||
{"id": 12, "subject": "Task 12 (P1): Typed AdminUI editor + validator (plain shape)", "status": "pending", "classification": "standard", "parallelizableWith": [11], "blockedBy": [2]},
|
"parallelizableWith": [],
|
||||||
{"id": 13, "subject": "Task 13 (P1): Mosquitto+JSON-publisher fixture (TLS+auth) + env-gated live suite", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [9]},
|
"blockedBy": [
|
||||||
{"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]},
|
0
|
||||||
{"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": 2,
|
||||||
{"id": 19, "subject": "Task 19 (P2): SequenceTracker seq-gap + bdSeq death-tie", "status": "pending", "classification": "high-risk", "parallelizableWith": [18], "blockedBy": [16, 17]},
|
"subject": "Task 2 (P1): .Contracts MqttTagDefinition + MqttEquipmentTagParser (plain, strict enum)",
|
||||||
{"id": 20, "subject": "Task 20 (P2): RebirthRequester NCMD encode", "status": "pending", "classification": "small", "parallelizableWith": [], "blockedBy": [16]},
|
"status": "completed",
|
||||||
{"id": 21, "subject": "Task 21 (P2): Sparkplug ingest state machine — the 3.6 matrix", "status": "pending", "classification": "high-risk", "parallelizableWith": [], "blockedBy": [18, 19, 20]},
|
"classification": "standard",
|
||||||
{"id": 22, "subject": "Task 22 (P2): ITagDiscovery UntilStable + IRediscoverable (rediscover-on-DBIRTH)", "status": "pending", "classification": "standard", "parallelizableWith": [], "blockedBy": [21]},
|
"parallelizableWith": [],
|
||||||
{"id": 23, "subject": "Task 23 (P2): Sparkplug browser tree + AttributesAsync + scoped RequestRebirthAsync", "status": "pending", "classification": "standard", "parallelizableWith": [24], "blockedBy": [20, 21]},
|
"blockedBy": [
|
||||||
{"id": 24, "subject": "Task 24 (P2): AdminUI editor Sparkplug mode shape + validator", "status": "pending", "classification": "small", "parallelizableWith": [23], "blockedBy": [12, 17]},
|
1
|
||||||
{"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": 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",
|
"planPath": "docs/plans/2026-07-24-mtconnect-driver.md",
|
||||||
"tasks": [
|
"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": 0,
|
||||||
{"id": 2, "subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts", "status": "pending", "blockedBy": [1]},
|
"subject": "Task 0: Verify TrakHound MTConnect.NET pin (or record the hand-roll decision)",
|
||||||
{"id": 3, "subject": "Task 3: MTConnectDataTypeInference.Infer + golden type-map test", "status": "pending", "blockedBy": [1]},
|
"status": "completed"
|
||||||
{"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": 1,
|
||||||
{"id": 7, "subject": "Task 7: MTConnectAgentClient — parse /current + /sample, detect the sequence gap", "status": "pending", "blockedBy": [4, 5, 6]},
|
"subject": "Task 1: Scaffold the two driver projects + the test project",
|
||||||
{"id": 8, "subject": "Task 8: MTConnectObservationIndex + UNAVAILABLE -> BadNoCommunication", "status": "pending", "blockedBy": [7]},
|
"status": "completed",
|
||||||
{"id": 9, "subject": "Task 9: MTConnectDriver shell — IDriver lifecycle", "status": "pending", "blockedBy": [2, 8]},
|
"blockedBy": [
|
||||||
{"id": 10, "subject": "Task 10: IReadable.ReadAsync — /current, ordered snapshots", "status": "pending", "blockedBy": [9]},
|
0
|
||||||
{"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": 2,
|
||||||
{"id": 15, "subject": "Task 15: MTConnectDriverFactoryExtensions", "status": "pending", "blockedBy": [2, 9]},
|
"subject": "Task 2: MTConnectDriverOptions + MTConnectTagDefinition in .Contracts",
|
||||||
{"id": 16, "subject": "Task 16: Host registration + DriverTypeNames.MTConnect + guard-test parity", "status": "pending", "blockedBy": [14, 15]},
|
"status": "completed",
|
||||||
{"id": 17, "subject": "Task 17: AdminUI typed model MTConnectTagConfigModel", "status": "pending", "blockedBy": [3, 16]},
|
"blockedBy": [
|
||||||
{"id": 18, "subject": "Task 18: AdminUI editor razor + map/validator registration", "status": "pending", "blockedBy": [17]},
|
1
|
||||||
{"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": 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",
|
"planPath": "docs/plans/2026-07-24-sql-poll-driver.md",
|
||||||
"tasks": [
|
"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": 0,
|
||||||
{"id": 2, "subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)", "status": "pending", "blockedBy": [1]},
|
"subject": "Task 0: Scaffold Driver.Sql.Contracts project + register in slnx",
|
||||||
{"id": 3, "subject": "Task 3: Scaffold Driver.Sql runtime project + register in slnx", "status": "pending", "blockedBy": [0]},
|
"status": "completed"
|
||||||
{"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": 1,
|
||||||
{"id": 7, "subject": "Task 7: SqlPollReader core — grouped read, bounded deadline, ordered slice-back", "status": "pending", "blockedBy": [5, 6]},
|
"subject": "Task 1: SqlProvider/SqlTagModel enums + SqlDriverConfigDto",
|
||||||
{"id": 8, "subject": "Task 8: SqlDriver shell — IDriver/ITagDiscovery/IReadable/ISubscribable/IHostConnectivityProbe", "status": "pending", "blockedBy": [7]},
|
"status": "completed",
|
||||||
{"id": 9, "subject": "Task 9: SqlDriverFactoryExtensions + connectionStringRef env resolution + enum guard", "status": "pending", "blockedBy": [8]},
|
"blockedBy": [
|
||||||
{"id": 10, "subject": "Task 10: SqlDriverProbe (SELECT 1 liveness)", "status": "pending", "blockedBy": [4, 6]},
|
0
|
||||||
{"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": 2,
|
||||||
{"id": 15, "subject": "Task 15: AdminUI browser DI + SqlAddressPickerBody.razor", "status": "pending", "blockedBy": [14]},
|
"subject": "Task 2: SqlTagDefinition + SqlEquipmentTagParser (strict enums, malicious-input safe)",
|
||||||
{"id": 16, "subject": "Task 16: Env-gated central-SQL integration fixture + read round-trip", "status": "pending", "blockedBy": [8]},
|
"status": "completed",
|
||||||
{"id": 17, "subject": "Task 17: Injection regression test (bind value / reject identifier)", "status": "pending", "blockedBy": [7]},
|
"blockedBy": [
|
||||||
{"id": 18, "subject": "Task 18: Blackhole/timeout live-gate on a dedicated mssql container", "status": "pending", "blockedBy": [16]},
|
1
|
||||||
{"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": 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).
|
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.
|
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`.
|
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.
|
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 |
|
| 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. |
|
| 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 attached to the OPC UA session identity for the downstream ACL evaluator. |
|
| 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); finer-grain authorization happens through the data-plane ACLs. |
|
| 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`.
|
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.
|
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".
|
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):
|
`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
|
||||||
|
|
||||||
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:
|
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.
|
`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.
|
Intended properties, of which only the last is true today:
|
||||||
- **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.
|
- **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
|
### 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)
|
### Role grant source (data-plane)
|
||||||
|
|
||||||
@@ -271,23 +356,25 @@ to those role strings via `GroupToRole`, e.g.:
|
|||||||
```json
|
```json
|
||||||
"GroupToRole": {
|
"GroupToRole": {
|
||||||
"ot-operators": "WriteOperate",
|
"ot-operators": "WriteOperate",
|
||||||
"ot-tuners": "WriteTune",
|
"ot-alarm-ack": "AlarmAck"
|
||||||
"ot-engineers": "WriteConfigure",
|
|
||||||
"ot-alarm-ack": "AlarmAck",
|
|
||||||
"ot-readonly": "ReadOnly"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If this mapping is absent the data-plane evaluator is strictly default-deny: inbound operator writes
|
⚠️ **Only two role strings do anything.** `OpcUaDataPlaneRoles` declares exactly `WriteOperate` and
|
||||||
and OPC UA Part-9 alarm acknowledgement all return `BadUserAccessDenied` even for users who
|
`AlarmAck`, and those are the only values any gate compares against. `ReadOnly`, `WriteTune` and
|
||||||
authenticate successfully. (The same requirement gates both the scripted-alarm and the native
|
`WriteConfigure` appear in the permission vocabulary but **no code path reads them** — mapping a group
|
||||||
Galaxy-alarm Part-9 ack/confirm/shelve paths.)
|
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
|
If this mapping is absent, **writes and alarm acknowledgement** default-deny: they return
|
||||||
against the constants in `OpcUaDataPlaneRoles` (`AlarmAck`, `WriteOperate`) and the bare strings
|
`BadUserAccessDenied` even for users who authenticate successfully. (The same requirement gates both
|
||||||
`ReadOnly` / `WriteTune` / `WriteConfigure`. In particular the alarm-ack role is `AlarmAck`, **not**
|
the scripted-alarm and the native Galaxy-alarm Part-9 ack/confirm/shelve paths.) **Reads, browses,
|
||||||
`AlarmAcknowledge` (that spelling is the `PermissionFlags` ACL bit, a different vocabulary); a
|
subscribes and history reads are unaffected** — they succeed with or without any `GroupToRole` entry.
|
||||||
`GroupToRole` value of `AlarmAcknowledge` silently never satisfies the ack gate.
|
|
||||||
|
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,23 @@
|
|||||||
# OPC UA Client Authorization (ACL Design) — OtOpcUa v2
|
# 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).
|
> **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).
|
||||||
>
|
>
|
||||||
> **Branch**: `v2`
|
> **Branch**: `v2`
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# Driver Stability & Isolation — OtOpcUa v2
|
# 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.
|
> **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`
|
> **Branch**: `v2`
|
||||||
|
|||||||
@@ -37,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.
|
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.
|
**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.
|
- ~~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).
|
- ~~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="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="ErrorCount5Min">Number of state-transitions into Faulted in the last 5 minutes.</param>
|
||||||
/// <param name="PublishedUtc">Timestamp this snapshot was published.</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(
|
public sealed record DriverHealthChanged(
|
||||||
string ClusterId,
|
string ClusterId,
|
||||||
string DriverInstanceId,
|
string DriverInstanceId,
|
||||||
@@ -20,7 +33,9 @@ public sealed record DriverHealthChanged(
|
|||||||
DateTime? LastSuccessfulReadUtc,
|
DateTime? LastSuccessfulReadUtc,
|
||||||
string? LastError,
|
string? LastError,
|
||||||
int ErrorCount5Min,
|
int ErrorCount5Min,
|
||||||
DateTime PublishedUtc)
|
DateTime PublishedUtc,
|
||||||
|
DateTime? RediscoveryNeededUtc = null,
|
||||||
|
string? RediscoveryReason = null)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
|
/// 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
|
optional string last_error = 5; // nullable in the record
|
||||||
int32 error_count_5min = 6;
|
int32 error_count_5min = 6;
|
||||||
google.protobuf.Timestamp published_utc = 7;
|
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.
|
// 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
|
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
|
||||||
/// constants and the set the driver factories actually register, so a rename on
|
/// constants and the set the driver factories actually register, so a rename on
|
||||||
/// either side breaks the build's test gate. Only constants for
|
/// either side breaks the build's test gate. Only constants for
|
||||||
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver
|
/// <b>currently-registered</b> factories belong here — a driver adds its own constant
|
||||||
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own
|
/// when its factory is wired in. (This used to name Calculation as the example of a
|
||||||
/// constant when its factory is wired in.
|
/// not-yet-registered driver; it registers in <c>DriverFactoryBootstrap</c> like the rest.)
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class DriverTypeNames
|
public static class DriverTypeNames
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ public interface IDriverHealthPublisher
|
|||||||
/// <param name="driverInstanceId">The driver instance the snapshot describes.</param>
|
/// <param name="driverInstanceId">The driver instance the snapshot describes.</param>
|
||||||
/// <param name="health">The current health snapshot.</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="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(
|
void Publish(
|
||||||
string clusterId,
|
string clusterId,
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
DriverHealth health,
|
DriverHealth health,
|
||||||
int errorCount5Min);
|
int errorCount5Min,
|
||||||
|
DateTime? rediscoveryNeededUtc = null,
|
||||||
|
string? rediscoveryReason = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -38,6 +47,8 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
|
|||||||
string clusterId,
|
string clusterId,
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
DriverHealth health,
|
DriverHealth health,
|
||||||
int errorCount5Min)
|
int errorCount5Min,
|
||||||
|
DateTime? rediscoveryNeededUtc = null,
|
||||||
|
string? rediscoveryReason = null)
|
||||||
{ /* no-op */ }
|
{ /* no-op */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,4 +38,32 @@ public interface ITagDiscovery
|
|||||||
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
|
/// See docs/plans/2026-07-15-universal-discovery-browser-design.md §5.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool SupportsOnlineDiscovery => false;
|
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>
|
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||||
public bool SupportsOnlineDiscovery => true;
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
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,
|
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
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 string _driverInstanceId;
|
||||||
private readonly IAbLegacyTagFactory _tagFactory;
|
private readonly IAbLegacyTagFactory _tagFactory;
|
||||||
private readonly ILogger<AbLegacyDriver> _logger;
|
private readonly ILogger<AbLegacyDriver> _logger;
|
||||||
@@ -94,12 +96,28 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DriverType => "AbLegacy";
|
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 />
|
/// <inheritdoc />
|
||||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
try
|
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)
|
foreach (var device in _options.Devices)
|
||||||
{
|
{
|
||||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
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>
|
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
|
||||||
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
|
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
|
||||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
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(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -82,10 +99,7 @@ public static class AbLegacyDriverFactoryExtensions
|
|||||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new AbLegacyDriver(
|
return options;
|
||||||
options, driverInstanceId,
|
|
||||||
tagFactory: null,
|
|
||||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|||||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
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 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 PollGroupEngine _poll;
|
||||||
private readonly ILogger<FocasDriver> _logger;
|
private readonly ILogger<FocasDriver> _logger;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
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="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||||
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
|
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
|
||||||
/// <param name="logger">Optional logger instance.</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,
|
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||||
IFocasClientFactory? clientFactory = null,
|
IFocasClientFactory? clientFactory = null,
|
||||||
ILogger<FocasDriver>? logger = null)
|
ILogger<FocasDriver>? logger = null,
|
||||||
|
Func<string, FocasDriverBinding>? rebind = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||||
|
_rebind = rebind;
|
||||||
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
_logger = logger ?? NullLogger<FocasDriver>.Instance;
|
||||||
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
|
||||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||||
@@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
backoffCap: PollBackoffCap);
|
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>
|
/// <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);
|
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
@@ -100,12 +125,37 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DriverType => "FOCAS";
|
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)
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||||
try
|
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
|
// 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
|
// see an actionable error at init rather than a phantom-Healthy driver that fails
|
||||||
// every read/write/subscribe silently.
|
// 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>
|
/// until the node set is non-empty and stable.</summary>
|
||||||
public bool SupportsOnlineDiscovery => true;
|
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 />
|
/// <inheritdoc />
|
||||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions
|
|||||||
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
|
||||||
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
|
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
|
||||||
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
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(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions
|
|||||||
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
|
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
|
||||||
};
|
};
|
||||||
|
|
||||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
|
||||||
return new FocasDriver(options, driverInstanceId, clientFactory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions
|
|||||||
public TimeSpan? Interval { get; init; }
|
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>
|
/// </remarks>
|
||||||
public bool SupportsOnlineDiscovery => true;
|
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/>
|
/// <inheritdoc/>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
|
/// <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) ----
|
// ---- 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 Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
private readonly ILogger<ModbusDriver> _logger;
|
private readonly ILogger<ModbusDriver> _logger;
|
||||||
@@ -188,12 +190,29 @@ public sealed class ModbusDriver
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DriverType => "Modbus";
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||||
try
|
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);
|
_transport = _transportFactory(_options);
|
||||||
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
|
// 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>
|
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||||
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
|
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
|
||||||
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
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(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
@@ -106,10 +123,7 @@ public static class ModbusDriverFactoryExtensions
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return new ModbusDriver(
|
return options;
|
||||||
options, driverInstanceId,
|
|
||||||
transportFactory: null,
|
|
||||||
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
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;
|
_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 ISecretResolver _secretResolver;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
// ---- IAlarmSource state ----
|
// ---- IAlarmSource state ----
|
||||||
@@ -156,12 +158,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string DriverType => "OpcUaClient";
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
try
|
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
|
// Enforce the Equipment-vs-SystemPlatform choice at startup per driver-specs.md
|
||||||
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
|
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
|
||||||
// not as a runtime surprise.
|
// not as a runtime surprise.
|
||||||
|
|||||||
+20
-5
@@ -63,16 +63,31 @@ public static class OpcUaClientDriverFactoryExtensions
|
|||||||
public static OpcUaClientDriver CreateInstance(
|
public static OpcUaClientDriver CreateInstance(
|
||||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
||||||
ISecretResolver? secretResolver = 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(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
$"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,
|
Name: column.Name,
|
||||||
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
DriverDataType: _dialect.MapColumnType(column.DataType).ToString(),
|
||||||
IsArray: false,
|
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.Data.Common;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
@@ -51,16 +52,19 @@ public sealed class SqlDriver
|
|||||||
|
|
||||||
// ---- instance fields (grouped at top for auditability) ----
|
// ---- 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 string _driverInstanceId;
|
||||||
private readonly ISqlDialect _dialect;
|
/// <summary>Config-derived, so mutable: <see cref="ApplyBinding"/> swaps it with the connection string
|
||||||
private readonly DbProviderFactory _factory;
|
/// and options together on a reinitialize (#516).</summary>
|
||||||
|
private ISqlDialect _dialect;
|
||||||
|
private DbProviderFactory _factory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
|
/// 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.
|
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly string _connectionString;
|
private string _connectionString;
|
||||||
|
|
||||||
private readonly ILogger<SqlDriver> _logger;
|
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>
|
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
|
||||||
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
|
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>
|
/// <summary>
|
||||||
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
|
/// 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.
|
/// delegate must read this driver's live authored table, which does not exist before the driver does.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly SqlPollReader _reader;
|
private SqlPollReader _reader;
|
||||||
|
|
||||||
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
|
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
|
||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
@@ -133,6 +145,11 @@ public sealed class SqlDriver
|
|||||||
/// explicitly by tests that need to observe or delay connection creation.
|
/// explicitly by tests that need to observe or delay connection creation.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="logger">Optional; defaults to a no-op logger.</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="ArgumentNullException">A required reference argument is null.</exception>
|
||||||
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
|
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
|
||||||
public SqlDriver(
|
public SqlDriver(
|
||||||
@@ -141,7 +158,8 @@ public sealed class SqlDriver
|
|||||||
ISqlDialect dialect,
|
ISqlDialect dialect,
|
||||||
string connectionString,
|
string connectionString,
|
||||||
DbProviderFactory? factory = null,
|
DbProviderFactory? factory = null,
|
||||||
ILogger<SqlDriver>? logger = null)
|
ILogger<SqlDriver>? logger = null,
|
||||||
|
Func<string, SqlDriverBinding>? rebind = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentNullException.ThrowIfNull(dialect);
|
ArgumentNullException.ThrowIfNull(dialect);
|
||||||
@@ -149,15 +167,47 @@ public sealed class SqlDriver
|
|||||||
if (string.IsNullOrWhiteSpace(connectionString))
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
|
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
|
||||||
|
|
||||||
_options = options;
|
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_dialect = dialect;
|
|
||||||
_factory = factory ?? dialect.Factory;
|
|
||||||
_connectionString = connectionString;
|
|
||||||
_logger = logger ?? NullLogger<SqlDriver>.Instance;
|
_logger = logger ?? NullLogger<SqlDriver>.Instance;
|
||||||
Endpoint = DescribeEndpoint(connectionString);
|
_explicitFactory = factory;
|
||||||
|
_rebind = rebind;
|
||||||
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
|
_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(
|
_reader = new SqlPollReader(
|
||||||
_factory,
|
_factory,
|
||||||
connectionString,
|
connectionString,
|
||||||
@@ -168,12 +218,16 @@ public sealed class SqlDriver
|
|||||||
nullIsBad: options.NullIsBad,
|
nullIsBad: options.NullIsBad,
|
||||||
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
|
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
|
||||||
logger: _logger);
|
logger: _logger);
|
||||||
_poll = new PollGroupEngine(
|
}
|
||||||
reader: ReadAsync,
|
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
|
||||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
|
||||||
onError: HandlePollError,
|
/// without a config — those keep the constructor-supplied trio.</summary>
|
||||||
backoffCap: PollBackoffCap);
|
private static bool HasConfigBody(string? driverConfigJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||||
|
var trimmed = driverConfigJson.Trim();
|
||||||
|
return trimmed is not "{}" and not "[]";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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
|
/// 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.
|
/// appear in a log, a health message, or the Admin UI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Endpoint { get; }
|
public string Endpoint { get; private set; } = "";
|
||||||
|
|
||||||
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
|
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
|
||||||
public string HostName => Endpoint;
|
public string HostName => Endpoint;
|
||||||
@@ -215,9 +269,20 @@ public sealed class SqlDriver
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the authored RawPath table and proves the database is reachable.
|
/// 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
|
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
|
||||||
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
|
/// the factory (Gitea #516). The original comment on this method claimed it was not, on the premise
|
||||||
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
|
/// 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
|
/// <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> —
|
/// 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
|
/// <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)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
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();
|
BuildTagTable();
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -81,6 +81,43 @@ public static class SqlDriverFactoryExtensions
|
|||||||
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
=> 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>
|
/// <summary>
|
||||||
/// Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
|
/// 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
|
/// <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(
|
public static SqlDriver CreateInstance(
|
||||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
var binding = ParseBinding(driverInstanceId, driverConfigJson);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
var dto = binding.Dto;
|
||||||
|
var dialect = binding.Dialect;
|
||||||
var dto = Deserialize(driverInstanceId, driverConfigJson);
|
var connectionString = binding.ConnectionString;
|
||||||
|
var options = binding.Options;
|
||||||
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 logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
|
var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
|
||||||
?? NullLogger.Instance;
|
?? NullLogger.Instance;
|
||||||
@@ -143,7 +166,11 @@ public static class SqlDriverFactoryExtensions
|
|||||||
dialect,
|
dialect,
|
||||||
connectionString,
|
connectionString,
|
||||||
factory: null,
|
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
|
// Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
|
||||||
// permitted outside the provider call.
|
// 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>
|
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||||
public bool SupportsOnlineDiscovery => true;
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ else if (!IsNew && _existing is null)
|
|||||||
}
|
}
|
||||||
else
|
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">
|
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="aclEdit">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
<section class="panel rise" style="animation-delay:.02s">
|
<section class="panel rise" style="animation-delay:.02s">
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
}
|
}
|
||||||
else
|
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">
|
<section class="panel notice rise" style="animation-delay:.02s">
|
||||||
ACL rows grant LDAP groups specific <span class="mono">NodePermissions</span> on a scope
|
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
|
(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>
|
<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>
|
||||||
<td>@(d.DriverType ?? "—")</td>
|
<td>@(d.DriverType ?? "—")</td>
|
||||||
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
|
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
|
||||||
|
|||||||
@@ -39,38 +39,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -120,6 +105,14 @@
|
|||||||
private string _name = "";
|
private string _name = "";
|
||||||
private bool _enabled = true;
|
private bool _enabled = true;
|
||||||
private string _deviceConfigJson = "{}";
|
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 byte[] _rowVersion = [];
|
||||||
private string _parentDriverConfig = "{}";
|
private string _parentDriverConfig = "{}";
|
||||||
|
|
||||||
|
|||||||
+26
-40
@@ -31,50 +31,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<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
|
@* Some drivers hold ONE connection per driver instance and author it here; the rest
|
||||||
their device forms are informational. Every other driver splits the endpoint onto
|
split the endpoint onto the device (the v3 endpoint→DeviceConfig split). The set is
|
||||||
the device (the v3 endpoint→DeviceConfig split). *@
|
DeviceFormMap.SingleConnectionDriverTypes — this used to hardcode Galaxy-or-Mqtt and
|
||||||
@if (_driverType is DriverTypeNames.Galaxy or DriverTypeNames.Mqtt)
|
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">
|
<p class="form-text mt-3 mb-0">
|
||||||
This driver holds a single connection, authored above. Test-connect lives on its
|
This driver holds a single connection, authored above. Test-connect lives on its
|
||||||
@@ -133,6 +108,17 @@
|
|||||||
private string _driverType = "";
|
private string _driverType = "";
|
||||||
private string _name = "";
|
private string _name = "";
|
||||||
private string _driverConfigJson = "{}";
|
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 string? _resilienceConfig;
|
||||||
private byte[] _rowVersion = [];
|
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
|
@* 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
|
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
|
the Configure-driver modal. Name is inline-validated as a RawPath segment. Markup mirrors the other
|
||||||
(its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other
|
|
||||||
/raw modal shells. *@
|
/raw modal shells. *@
|
||||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
@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>
|
/// <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; }
|
[Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; }
|
||||||
|
|
||||||
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is
|
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway". Guarded against
|
||||||
// authorable now though its factory arrives in Wave C.
|
// DriverTypeNames by RawDriverTypeDialogParityTests, which reads this field by reflection.
|
||||||
private static readonly (string Label, string Value)[] Types =
|
private static readonly (string Label, string Value)[] Types =
|
||||||
[
|
[
|
||||||
("Modbus", DriverTypeNames.Modbus),
|
("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="LastError">Latest error message; null when none.</param>
|
||||||
/// <param name="ErrorCount5Min">Faulted-transition count in the last 5 minutes.</param>
|
/// <param name="ErrorCount5Min">Faulted-transition count in the last 5 minutes.</param>
|
||||||
/// <param name="PublishedUtc">Timestamp the snapshot was published.</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(
|
public sealed record HostsDriverRow(
|
||||||
string DriverInstanceId, string? Name, string? DriverType, string State,
|
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>
|
/// <summary>
|
||||||
/// One cluster's section on the <c>/hosts</c> page: its configured nodes plus its enriched
|
/// 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.LastSuccessfulReadUtc,
|
||||||
s.LastError,
|
s.LastError,
|
||||||
s.ErrorCount5Min,
|
s.ErrorCount5Min,
|
||||||
s.PublishedUtc);
|
s.PublishedUtc,
|
||||||
|
s.RediscoveryNeededUtc,
|
||||||
|
s.RediscoveryReason);
|
||||||
})
|
})
|
||||||
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||||
.ThenBy(d => 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.Commons.Types;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
@@ -139,9 +140,19 @@ public static class RawBrowseCommitMapper
|
|||||||
return new MTConnectTagConfigModel { FullName = address }.ToJson();
|
return new MTConnectTagConfigModel { FullName = address }.ToJson();
|
||||||
if (Is(driverType, DriverTypeNames.Galaxy))
|
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||||
return WriteSingleKey("attributeRef", address);
|
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
|
// 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);
|
return WriteSingleKey("address", address);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +272,41 @@ public static class RawBrowseCommitMapper
|
|||||||
private static bool Is(string driverType, string name)
|
private static bool Is(string driverType, string name)
|
||||||
=> string.Equals(driverType, name, StringComparison.OrdinalIgnoreCase);
|
=> 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)
|
private static string WriteSingleKey(string key, string value)
|
||||||
{
|
{
|
||||||
var o = new JsonObject { [key] = value };
|
var o = new JsonObject { [key] = value };
|
||||||
|
|||||||
@@ -319,9 +319,11 @@ public static class CsvColumnMap
|
|||||||
public static IReadOnlyList<string> CommonColumns { get; } =
|
public static IReadOnlyList<string> CommonColumns { get; } =
|
||||||
[.. CommonLeadingColumns, TagConfigJsonColumn];
|
[.. CommonLeadingColumns, TagConfigJsonColumn];
|
||||||
|
|
||||||
/// <summary>The Calculation (virtual-tag) driver-type string. Not yet a <see cref="DriverTypeNames"/>
|
/// <summary>The Calculation (virtual-tag) driver-type string.</summary>
|
||||||
/// constant (its factory lands in a later Batch-2 package), so it is pinned here for the CSV surface.</summary>
|
/// <remarks>Retained as an alias for <see cref="DriverTypeNames.Calculation"/>, which now exists — the
|
||||||
public const string CalculationDriverType = "Calculation";
|
/// 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();
|
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("ChangeTriggered", "changeTriggered", null), CsvRawKeyKind.Bool),
|
||||||
new CsvRawKeyColumn(new CsvTypedColumn("TimerIntervalMs", "timerIntervalMs", null), CsvRawKeyKind.Int),
|
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);
|
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.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
|
||||||
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
|
||||||
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
|
||||||
// Keyed off SqlDriver.DriverTypeName (= "Sql") rather than DriverTypeNames.Sql: that shared
|
// Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
|
||||||
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
|
// note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
|
||||||
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
// the factory" — went stale when the constant landed; the key is correct either way.
|
||||||
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||||
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
|
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
|
||||||
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
|
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ public static class TelemetryProtoMapCentral
|
|||||||
LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(),
|
LastSuccessfulReadUtc: msg.LastSuccessfulReadUtc?.ToDateTime(),
|
||||||
LastError: msg.HasLastError ? msg.LastError : null,
|
LastError: msg.HasLastError ? msg.LastError : null,
|
||||||
ErrorCount5Min: msg.ErrorCount5Min,
|
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>
|
/// <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);
|
msg.LastSuccessfulReadUtc = ToUtcTimestamp(e.LastSuccessfulReadUtc.Value);
|
||||||
if (e.LastError is not null)
|
if (e.LastError is not null)
|
||||||
msg.LastError = e.LastError;
|
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;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -886,49 +886,6 @@ public sealed class AddressSpaceApplier
|
|||||||
return failed;
|
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>
|
/// <summary>
|
||||||
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
|
/// 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 />
|
/// <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(
|
var msg = new DriverHealthChanged(
|
||||||
clusterId,
|
clusterId,
|
||||||
@@ -39,7 +45,9 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
|||||||
health.LastSuccessfulRead,
|
health.LastSuccessfulRead,
|
||||||
health.LastError,
|
health.LastError,
|
||||||
errorCount5Min,
|
errorCount5Min,
|
||||||
DateTime.UtcNow);
|
DateTime.UtcNow,
|
||||||
|
rediscoveryNeededUtc,
|
||||||
|
rediscoveryReason);
|
||||||
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
|
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
|
// 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.
|
// 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>
|
/// value maps so stale condition state never leaks across redeploys.</summary>
|
||||||
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
|
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
|
/// <summary>The composition from the most-recent apply (set at the END of
|
||||||
/// <see cref="PushDesiredSubscriptions"/>). Discovered-node injection
|
/// <see cref="PushDesiredSubscriptions"/>). Null until the first apply.</summary>
|
||||||
/// (<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>
|
|
||||||
private AddressSpaceComposition? _lastComposition;
|
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>
|
/// <summary>
|
||||||
/// Cached local <see cref="RedundancyRole"/> from the latest <see cref="RedundancyStateChanged"/>
|
/// 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
|
/// 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.AttributeValuePublished>(ForwardToMux);
|
||||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
|
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||||
@@ -928,8 +906,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
|
||||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||||
Receive<DriverInstanceActor.DiscoveredNodesReady>(HandleDiscoveredNodes);
|
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
|
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
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>
|
/// <summary>
|
||||||
/// Routes a native alarm transition (published by a driver child as
|
/// Routes a native alarm transition (published by a driver child as
|
||||||
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
|
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
|
||||||
@@ -1719,16 +1354,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
Receive<RouteNativeAlarmAck>(msg =>
|
Receive<RouteNativeAlarmAck>(msg =>
|
||||||
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
|
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
|
||||||
_localNode, msg.ConditionNodeId));
|
_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
|
// 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).
|
// it (the post-recovery rebuild re-materialises conditions, and the child re-announces on its next entry).
|
||||||
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
Receive<DriverInstanceActor.ConnectivityChanged>(_ => { });
|
||||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
// 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).
|
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||||
|
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||||
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
|
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
|
// 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
|
// 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
|
// 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;
|
var total = 0;
|
||||||
foreach (var (driverId, entry) in _children)
|
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);
|
total += SendAuthoredOnly(entry.Actor, driverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2380,94 +2000,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
_localNode, composition.EquipmentScriptedAlarms.Count);
|
_localNode, composition.EquipmentScriptedAlarms.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the applied composition LAST so discovered-node injection (HandleDiscoveredNodes) can resolve
|
// Cache the applied composition LAST. Set here (not in ApplyAndAck) so both the fresh-apply and
|
||||||
// the equipment bound to a driver + recompute the authored subscription sets when a driver later
|
// bootstrap-restore paths — which both route through this method — leave a current composition.
|
||||||
// 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.
|
|
||||||
_lastComposition = 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)
|
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); }
|
try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); }
|
||||||
catch (Exception ex)
|
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);
|
_localNode, spec.DriverType, spec.DriverInstanceId);
|
||||||
}
|
}
|
||||||
if (driver is null)
|
if (driver is null)
|
||||||
@@ -2559,15 +2103,54 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
|||||||
Context.Stop(adapter);
|
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)
|
private void ApplyChildDelta(DriverInstanceSpec spec)
|
||||||
{
|
{
|
||||||
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
||||||
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId()));
|
var correlation = CorrelationId.NewId();
|
||||||
// Store the full new spec — a delta can change Name, Enabled, ClusterId, etc. in addition to config.
|
_pendingDelta[correlation] = spec;
|
||||||
_children[spec.DriverInstanceId] = entry with { Spec = spec };
|
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self);
|
||||||
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
|
_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>
|
/// <summary>
|
||||||
/// A driver child finished applying an in-place <see cref="DriverInstanceActor.ApplyDelta"/> (its
|
/// 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
|
/// <see cref="IDriver.ReinitializeAsync"/> completed). Re-register THAT driver's dependency-consumer
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Akka.Event;
|
using Akka.Event;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
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);
|
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
/// <summary>Default interval between bounded post-connect re-discovery passes.</summary>
|
/// <summary>Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
|
||||||
public static readonly TimeSpan DefaultRediscoverInterval = TimeSpan.FromSeconds(2);
|
/// 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>
|
||||||
/// <summary>Default cap on the number of post-connect re-discovery passes.</summary>
|
public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
|
||||||
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);
|
|
||||||
|
|
||||||
public sealed record InitializeRequested(string DriverConfigJson);
|
public sealed record InitializeRequested(string DriverConfigJson);
|
||||||
public sealed record InitializeSucceeded(int Generation);
|
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
|
/// 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>
|
/// <see cref="IAlarmSource.SubscribeAlarmsAsync"/> call is bounded + off the synchronous handlers.</summary>
|
||||||
private sealed record SubscribeAlarms;
|
private sealed record SubscribeAlarms;
|
||||||
/// <summary>Published to the parent (DriverHostActor) after each post-connect discovery pass so it can
|
/// <summary>Self-sent when the wrapped driver raises <see cref="IRediscoverable.OnRediscoveryNeeded"/> —
|
||||||
/// graft the driver's discovered FixedTree nodes under the equipment. Empty/duplicate sets are fine —
|
/// it observed that the remote's tag set may have changed. Marshals the event off the driver's thread
|
||||||
/// the parent dedups and injection is idempotent.</summary>
|
/// onto the actor thread. Handled in EVERY behaviour, including Stubbed and Reconnecting: the raise has no
|
||||||
public sealed record DiscoveredNodesReady(string DriverInstanceId, IReadOnlyList<DiscoveredNode> Nodes);
|
/// 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>
|
/// <summary>Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
|
||||||
/// Sent by <see cref="DriverHostActor"/> to ask this driver child to re-run post-connect discovery
|
/// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
|
||||||
/// after the host rebinds the driver to a new equipment. Handled only in <c>Connected</c>, where it
|
/// <see cref="QualifiesForDriftCheck"/>.</summary>
|
||||||
/// re-kicks <see cref="StartDiscovery"/> — which already honours the driver's
|
private sealed record DriftCheckTick
|
||||||
/// <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:
|
public static readonly DriftCheckTick Instance = new();
|
||||||
/// the driver's eventual (re)connect re-discovers anyway, so there is nothing to do and nothing to log.
|
private DriftCheckTick() { }
|
||||||
/// </summary>
|
}
|
||||||
public sealed record TriggerRediscovery;
|
|
||||||
|
|
||||||
/// <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 sealed class RetryConnect
|
||||||
{
|
{
|
||||||
public static readonly RetryConnect Instance = new();
|
public static readonly RetryConnect Instance = new();
|
||||||
@@ -167,19 +160,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
private readonly IDriverHealthPublisher _healthPublisher;
|
private readonly IDriverHealthPublisher _healthPublisher;
|
||||||
private readonly TimeSpan _reconnectInterval;
|
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;
|
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 readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
private string? _currentConfigJson;
|
private string? _currentConfigJson;
|
||||||
|
|
||||||
@@ -203,6 +185,24 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
private ISubscriptionHandle? _subscriptionHandle;
|
private ISubscriptionHandle? _subscriptionHandle;
|
||||||
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
|
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
|
||||||
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
|
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"/>).
|
/// <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>
|
/// 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>
|
/// stub paths don't need to provide one.</param>
|
||||||
/// <param name="clusterId">Optional cluster identifier forwarded in <see cref="DriverHealthChanged"/> messages;
|
/// <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>
|
/// 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
|
/// <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>
|
/// 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
|
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
|
||||||
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
|
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
|
||||||
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
|
/// <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,
|
bool startStubbed = false,
|
||||||
IDriverHealthPublisher? healthPublisher = null,
|
IDriverHealthPublisher? healthPublisher = null,
|
||||||
string? clusterId = null,
|
string? clusterId = null,
|
||||||
TimeSpan? rediscoverInterval = null,
|
|
||||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
|
||||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
|
||||||
IDriverCapabilityInvoker? invoker = null,
|
IDriverCapabilityInvoker? invoker = null,
|
||||||
TimeSpan? healthPollInterval = null) =>
|
TimeSpan? healthPollInterval = null,
|
||||||
|
TimeSpan? driftCheckInterval = null) =>
|
||||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||||
driver,
|
driver,
|
||||||
reconnectInterval ?? DefaultReconnectInterval,
|
reconnectInterval ?? DefaultReconnectInterval,
|
||||||
startStubbed,
|
startStubbed,
|
||||||
healthPublisher ?? NullDriverHealthPublisher.Instance,
|
healthPublisher ?? NullDriverHealthPublisher.Instance,
|
||||||
clusterId ?? string.Empty,
|
clusterId ?? string.Empty,
|
||||||
rediscoverInterval,
|
|
||||||
rediscoverMaxAttempts,
|
|
||||||
rediscoverDiscoverTimeout,
|
|
||||||
invoker,
|
invoker,
|
||||||
healthPollInterval));
|
healthPollInterval,
|
||||||
|
driftCheckInterval));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
/// 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="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="healthPublisher">Sink for health-change notifications; must not be null.</param>
|
||||||
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</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;
|
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
|
||||||
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
|
/// 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
|
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
|
||||||
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
|
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
|
||||||
public DriverInstanceActor(
|
public DriverInstanceActor(
|
||||||
@@ -307,21 +301,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
bool startStubbed = false,
|
bool startStubbed = false,
|
||||||
IDriverHealthPublisher? healthPublisher = null,
|
IDriverHealthPublisher? healthPublisher = null,
|
||||||
string? clusterId = null,
|
string? clusterId = null,
|
||||||
TimeSpan? rediscoverInterval = null,
|
|
||||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
|
||||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
|
||||||
IDriverCapabilityInvoker? invoker = null,
|
IDriverCapabilityInvoker? invoker = null,
|
||||||
TimeSpan? healthPollInterval = null)
|
TimeSpan? healthPollInterval = null,
|
||||||
|
TimeSpan? driftCheckInterval = null)
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
||||||
_driverInstanceId = driver.DriverInstanceId;
|
_driverInstanceId = driver.DriverInstanceId;
|
||||||
_clusterId = clusterId ?? string.Empty;
|
_clusterId = clusterId ?? string.Empty;
|
||||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||||
|
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
|
||||||
_reconnectInterval = reconnectInterval;
|
_reconnectInterval = reconnectInterval;
|
||||||
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
|
|
||||||
_rediscoverMaxAttempts = rediscoverMaxAttempts;
|
|
||||||
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
|
|
||||||
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
|
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
|
||||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||||
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
|
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
|
// 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
|
// 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.
|
// 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();
|
PublishHealthSnapshot();
|
||||||
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
|
||||||
}
|
}
|
||||||
@@ -362,9 +355,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
|
Receive<SetDesiredSubscriptions>(StoreDesiredSubscriptions);
|
||||||
// Stubbed drivers never enter Connected, so they never kick discovery; swallow defensively in case a
|
// 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.
|
// re-discovery self-tick is ever routed here so it doesn't surface as an Akka Unhandled message.
|
||||||
Receive<RediscoverTick>(_ => { });
|
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||||
// A TriggerRediscovery is meaningless to a stubbed (never-Connected) driver — silently ignore it.
|
|
||||||
Receive<TriggerRediscovery>(_ => { });
|
|
||||||
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +381,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
ResubscribeDesired();
|
ResubscribeDesired();
|
||||||
AttachAlarmSource();
|
AttachAlarmSource();
|
||||||
SubscribeDesiredAlarms();
|
SubscribeDesiredAlarms();
|
||||||
StartDiscovery();
|
StartDriftCheck();
|
||||||
});
|
});
|
||||||
Receive<InitializeFailed>(msg =>
|
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
|
// 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.
|
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
|
||||||
Receive<SubscribeAlarms>(_ => { });
|
Receive<SubscribeAlarms>(_ => { });
|
||||||
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
|
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||||
// 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<HealthPollTick>(_ => PublishHealthSnapshot());
|
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,34 +425,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
||||||
_driverInstanceId, msg.Reason);
|
_driverInstanceId, msg.Reason);
|
||||||
Timers.Cancel("rediscover");
|
|
||||||
DetachSubscription();
|
DetachSubscription();
|
||||||
RecordFault();
|
RecordFault();
|
||||||
|
StopDriftCheck();
|
||||||
Become(Reconnecting);
|
Become(Reconnecting);
|
||||||
PublishHealthSnapshot();
|
PublishHealthSnapshot();
|
||||||
});
|
});
|
||||||
Receive<ForceReconnect>(_ =>
|
Receive<ForceReconnect>(_ =>
|
||||||
{
|
{
|
||||||
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
|
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
|
||||||
Timers.Cancel("rediscover");
|
|
||||||
DetachSubscription();
|
DetachSubscription();
|
||||||
|
StopDriftCheck();
|
||||||
Become(Reconnecting);
|
Become(Reconnecting);
|
||||||
PublishHealthSnapshot();
|
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<WriteAttribute>(HandleWriteAsync);
|
||||||
ReceiveAsync<RouteAlarmAck>(HandleAcknowledgeAsync);
|
ReceiveAsync<RouteAlarmAck>(HandleAcknowledgeAsync);
|
||||||
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
|
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
|
||||||
@@ -486,6 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
SubscribeDesiredAlarms();
|
SubscribeDesiredAlarms();
|
||||||
});
|
});
|
||||||
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
|
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
|
||||||
|
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
|
||||||
Receive<DataChangeForward>(OnDataChangeForward);
|
Receive<DataChangeForward>(OnDataChangeForward);
|
||||||
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
|
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
|
||||||
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
|
// 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.
|
// to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter.
|
||||||
Receive<SubscriptionFailed>(msg =>
|
Receive<SubscriptionFailed>(msg =>
|
||||||
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
|
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
|
||||||
|
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||||
Receive<HealthPollTick>(_ =>
|
Receive<HealthPollTick>(_ =>
|
||||||
{
|
{
|
||||||
PublishHealthSnapshot();
|
PublishHealthSnapshot();
|
||||||
@@ -579,7 +553,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
ResubscribeDesired();
|
ResubscribeDesired();
|
||||||
AttachAlarmSource();
|
AttachAlarmSource();
|
||||||
SubscribeDesiredAlarms();
|
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
|
// 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.
|
// 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
|
// 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.
|
// this state; swallow it so it doesn't dead-letter — the next Connected entry re-subscribes.
|
||||||
Receive<SubscribeAlarms>(_ => { });
|
Receive<SubscribeAlarms>(_ => { });
|
||||||
// Likewise the attempt-0 re-discovery self-tick (sent on Connected entry) can be overtaken by an
|
Receive<RediscoveryRaised>(HandleRediscoveryRaised);
|
||||||
// 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<HealthPollTick>(_ => PublishHealthSnapshot());
|
Receive<HealthPollTick>(_ => PublishHealthSnapshot());
|
||||||
Timers.StartPeriodicTimer("retry-connect", RetryConnect.Instance, _reconnectInterval);
|
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
|
/// <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
|
/// 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>
|
/// 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;
|
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
|
/// <summary>Symmetric teardown — called from <see cref="DetachSubscription"/> and PostStop so a stale
|
||||||
/// handler never pushes to a disconnected actor.</summary>
|
/// handler never pushes to a disconnected actor.</summary>
|
||||||
private void DetachAlarmSource()
|
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.
|
/// <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>
|
/// 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 health = _driver.GetHealth();
|
||||||
var errorCount = ErrorCount5Min();
|
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))
|
if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint))
|
||||||
return;
|
return;
|
||||||
_lastPublishedFingerprint = fingerprint;
|
_lastPublishedFingerprint = fingerprint;
|
||||||
_healthPublisher.Publish(_clusterId, _driverInstanceId, health, errorCount);
|
_healthPublisher.Publish(
|
||||||
|
_clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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>
|
/// <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 />
|
/// <inheritdoc />
|
||||||
protected override void PostStop()
|
protected override void PostStop()
|
||||||
{
|
{
|
||||||
|
StopDriftCheck();
|
||||||
DetachSubscription();
|
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(); }
|
try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||||
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
|
catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
|
||||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|||||||
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param>
|
/// <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>
|
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
||||||
public sealed record DriverSpawnPlan(
|
public sealed record DriverSpawnPlan(
|
||||||
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
||||||
@@ -42,23 +47,33 @@ public static class DriverSpawnPlanner
|
|||||||
toStop.Add(id);
|
toStop.Add(id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn.
|
// ANY of the three config surfaces changing forces a stop + respawn, so the FACTORY is the
|
||||||
// A RESILIENCE-CONFIG change likewise forces a respawn: the CapabilityInvoker (and its
|
// single parse authority for everything a driver is built from.
|
||||||
// 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
|
// • DriverType — factory-bound, can't be reinitialized in place.
|
||||||
// cached pipelines on the respawn's Create call. (A pure DriverConfig change stays an
|
// • ResilienceConfig — the CapabilityInvoker (and its resolved options) binds to the child
|
||||||
// in-place delta — no reconnect — because it doesn't touch the resilience pipeline.)
|
// 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)
|
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);
|
toStop.Add(id);
|
||||||
toSpawn.Add(spec);
|
toSpawn.Add(spec);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
toDelta.Add(spec);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (id, spec) in targetById)
|
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(
|
public sealed record RebuildAddressSpace(
|
||||||
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null);
|
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);
|
public sealed record ServiceLevelChanged(byte ServiceLevel);
|
||||||
|
|
||||||
private readonly IOpcUaAddressSpaceSink _sink;
|
private readonly IOpcUaAddressSpaceSink _sink;
|
||||||
@@ -284,7 +275,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
|||||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||||
Receive<MaterialiseDiscoveredNodes>(HandleMaterialiseDiscovered);
|
|
||||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||||
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
|
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
|
||||||
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
|
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)
|
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
|
||||||
{
|
{
|
||||||
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
|
// 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
|
public sealed class TagConfigDriverTypeNameGuardTests
|
||||||
{
|
{
|
||||||
// Every DriverTypeNames constant that has a typed editor registered.
|
// Every DriverTypeNames constant that has a typed editor registered.
|
||||||
public static TheoryData<string> EditorMappedDriverTypes() =>
|
//
|
||||||
[
|
// Enumerated FROM the map rather than hand-written. The hand-written list this replaced guarded
|
||||||
DriverTypeNames.Modbus,
|
// against RENAMES but not against GAPS: a newly-added DriverTypeNames constant simply never appeared
|
||||||
DriverTypeNames.S7,
|
// here, so the guard stayed green while the map went unmapped — and it had already silently drifted,
|
||||||
DriverTypeNames.AbCip,
|
// omitting Galaxy, Sql and Mqtt. Pairing it with the coverage test below makes both directions real.
|
||||||
DriverTypeNames.AbLegacy,
|
public static TheoryData<string> EditorMappedDriverTypes()
|
||||||
DriverTypeNames.TwinCAT,
|
{
|
||||||
DriverTypeNames.FOCAS,
|
var data = new TheoryData<string>();
|
||||||
DriverTypeNames.OpcUaClient,
|
foreach (var t in DriverTypeNames.All.Where(t => TagConfigEditorMap.Resolve(t) is not null))
|
||||||
DriverTypeNames.Calculation,
|
{
|
||||||
DriverTypeNames.MTConnect,
|
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]
|
[Theory]
|
||||||
[MemberData(nameof(EditorMappedDriverTypes))]
|
[MemberData(nameof(EditorMappedDriverTypes))]
|
||||||
|
|||||||
-5
@@ -94,11 +94,6 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
|||||||
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
|
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
|
||||||
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
|
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
|
||||||
applier.MaterialiseScriptedAlarms(alarms).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 ----------------
|
// ---------------- fixtures ----------------
|
||||||
|
|||||||
@@ -8,679 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|||||||
|
|
||||||
public sealed class AddressSpaceApplierTests
|
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
|
/// <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
|
/// 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 " +
|
"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.";
|
"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
|
// DiscoveryInjectionDormantV3 was removed with the injection path itself (§8.2, Gitea #507). The 18
|
||||||
/// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
|
// tests it skipped were v2 characterization tests — they asserted an equipment-rooted graft
|
||||||
/// raw tags are authored explicitly via the Batch-2 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
|
// (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they were deleted rather than unskipped.
|
||||||
/// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
|
// Discovered raw tags are authored through the /raw browse-commit flow; IRediscoverable now surfaces a
|
||||||
/// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
|
// re-browse prompt instead (DriverInstanceActorRediscoverySignalTests).
|
||||||
/// <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.";
|
|
||||||
|
|
||||||
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
|
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
|
||||||
public const string EquipmentDeviceBindingRetired =
|
public const string EquipmentDeviceBindingRetired =
|
||||||
|
|||||||
@@ -1,119 +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 DiscoveredNodeMapperTests
|
|
||||||
{
|
|
||||||
private static DiscoveredNode Node(string[] path, string name, string fullRef,
|
|
||||||
DriverDataType dt = DriverDataType.Float64, bool writable = false)
|
|
||||||
=> new(path, name, name, fullRef, dt, false, null, writable, false);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Maps_under_equipment_collapsing_single_device_folder()
|
|
||||||
{
|
|
||||||
var nodes = new[]
|
|
||||||
{
|
|
||||||
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
|
|
||||||
Node(["FOCAS", "10.0.0.5:8193", "Axes", "X"], "AbsolutePosition", "10.0.0.5:8193/Axes/X/AbsolutePosition"),
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string>());
|
|
||||||
|
|
||||||
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
|
|
||||||
{
|
|
||||||
"EQ-1/FOCAS/Identity/SeriesNumber",
|
|
||||||
"EQ-1/FOCAS/Axes/X/AbsolutePosition",
|
|
||||||
}, ignoreOrder: true);
|
|
||||||
result.Folders.Select(f => f.NodeId).ShouldContain("EQ-1/FOCAS/Axes/X");
|
|
||||||
result.Folders.First(f => f.NodeId == "EQ-1/FOCAS/Axes/X").ParentNodeId.ShouldBe("EQ-1/FOCAS/Axes");
|
|
||||||
result.RoutingByRef["10.0.0.5:8193/Identity/SeriesNumber"].ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
|
|
||||||
result.Variables.First(v => v.NodeId.EndsWith("SeriesNumber")).Writable.ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dedups_authored_refs()
|
|
||||||
{
|
|
||||||
var nodes = new[]
|
|
||||||
{
|
|
||||||
Node(["FOCAS", "10.0.0.5:8193"], "parts-count", "parts-count"),
|
|
||||||
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "10.0.0.5:8193/Identity/SeriesNumber", DriverDataType.String),
|
|
||||||
};
|
|
||||||
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string> { "parts-count" });
|
|
||||||
result.Variables.ShouldHaveSingleItem();
|
|
||||||
result.Variables[0].NodeId.ShouldBe("EQ-1/FOCAS/Identity/SeriesNumber");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Does_not_collapse_when_two_devices_present()
|
|
||||||
{
|
|
||||||
var nodes = new[]
|
|
||||||
{
|
|
||||||
Node(["FOCAS", "10.0.0.5:8193", "Identity"], "SeriesNumber", "a", DriverDataType.String),
|
|
||||||
Node(["FOCAS", "10.0.0.6:8193", "Identity"], "SeriesNumber", "b", DriverDataType.String),
|
|
||||||
};
|
|
||||||
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string>());
|
|
||||||
result.Variables.Select(v => v.NodeId).ShouldBe(new[]
|
|
||||||
{
|
|
||||||
"EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber",
|
|
||||||
"EQ-1/FOCAS/10.0.0.6:8193/Identity/SeriesNumber",
|
|
||||||
}, ignoreOrder: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Empty_input_yields_empty_plan()
|
|
||||||
{
|
|
||||||
var result = DiscoveredNodeMapper.Map("EQ-1", Array.Empty<DiscoveredNode>(), authoredRefs: new HashSet<string>());
|
|
||||||
result.Folders.ShouldBeEmpty();
|
|
||||||
result.Variables.ShouldBeEmpty();
|
|
||||||
result.RoutingByRef.ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Array_metadata_passes_through_unchanged()
|
|
||||||
{
|
|
||||||
var node = new DiscoveredNode(
|
|
||||||
FolderPathSegments: ["FOCAS", "10.0.0.5:8193", "Axes"],
|
|
||||||
BrowseName: "Positions",
|
|
||||||
DisplayName: "Positions",
|
|
||||||
FullReference: "10.0.0.5:8193/Axes/Positions",
|
|
||||||
DataType: DriverDataType.Float64,
|
|
||||||
IsArray: true,
|
|
||||||
ArrayDim: 8u,
|
|
||||||
Writable: false,
|
|
||||||
IsHistorized: false);
|
|
||||||
|
|
||||||
var result = DiscoveredNodeMapper.Map("EQ-1", new[] { node }, authoredRefs: new HashSet<string>());
|
|
||||||
|
|
||||||
result.Variables.ShouldHaveSingleItem();
|
|
||||||
result.Variables[0].IsArray.ShouldBeTrue();
|
|
||||||
result.Variables[0].ArrayLength.ShouldBe(8u);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
// Mirror OtOpcUaNodeManager.ResolveBuiltInDataType's accepted string set: Float32 -> "Float",
|
|
||||||
// Float64 -> "Double", Reference (Galaxy attr ref encoded as a string) -> "String". The pass-through
|
|
||||||
// members must keep their enum name so the node manager resolves them to the matching built-in type.
|
|
||||||
[InlineData(DriverDataType.Float64, "Double")]
|
|
||||||
[InlineData(DriverDataType.Float32, "Float")]
|
|
||||||
[InlineData(DriverDataType.Reference, "String")]
|
|
||||||
[InlineData(DriverDataType.Boolean, "Boolean")]
|
|
||||||
[InlineData(DriverDataType.Int16, "Int16")]
|
|
||||||
[InlineData(DriverDataType.Int32, "Int32")]
|
|
||||||
[InlineData(DriverDataType.Int64, "Int64")]
|
|
||||||
[InlineData(DriverDataType.UInt16, "UInt16")]
|
|
||||||
[InlineData(DriverDataType.UInt32, "UInt32")]
|
|
||||||
[InlineData(DriverDataType.UInt64, "UInt64")]
|
|
||||||
[InlineData(DriverDataType.String, "String")]
|
|
||||||
[InlineData(DriverDataType.DateTime, "DateTime")]
|
|
||||||
public void DataType_maps_to_node_manager_builtin_string(DriverDataType dt, string expected)
|
|
||||||
{
|
|
||||||
var nodes = new[] { Node(["FOCAS", "10.0.0.5:8193", "Identity"], "Value", "10.0.0.5:8193/Identity/Value", dt) };
|
|
||||||
var result = DiscoveredNodeMapper.Map("EQ-1", nodes, authoredRefs: new HashSet<string>());
|
|
||||||
result.Variables.ShouldHaveSingleItem();
|
|
||||||
result.Variables[0].DataType.ShouldBe(expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-353
@@ -1,353 +0,0 @@
|
|||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Akka.Actor;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Task 9 — the focused END-TO-END proof that a driver-discovered FixedTree node is grafted into the
|
|
||||||
/// served Equipment OPC UA address space and a polled value reaches it. Unlike the Task-7/8 suites
|
|
||||||
/// (which wire the OPC UA publish side as a <see cref="Akka.TestKit.TestProbe"/> and assert on the
|
|
||||||
/// intercepted <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> /
|
|
||||||
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> messages), this suite wires the FULL real chain:
|
|
||||||
///
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>a real <see cref="DriverHostActor"/> (resolves equipment, maps via
|
|
||||||
/// <see cref="DiscoveredNodeMapper"/>, extends the live-value routing map, caches the plan);</item>
|
|
||||||
/// <item>a real <see cref="OpcUaPublishActor"/> as its <c>opcUaPublishActor</c> seam (so
|
|
||||||
/// <c>MaterialiseDiscoveredNodes</c> + <c>AttributeValueUpdate</c> are actually handled, not
|
|
||||||
/// intercepted);</item>
|
|
||||||
/// <item>a real <see cref="AddressSpaceApplier"/> over a recording
|
|
||||||
/// <see cref="IOpcUaAddressSpaceSink"/> (so the materialise + value-write reach the sink).</item>
|
|
||||||
/// </list>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// The assertions are therefore made on the SINK's recorded <c>EnsureVariable</c> /
|
|
||||||
/// <c>RaiseNodesAddedModelChange</c> / <c>WriteValue</c> calls — i.e. the discovered node was
|
|
||||||
/// materialised through the real applier AND a published value surfaces <see cref="OpcUaQuality.Good"/>
|
|
||||||
/// at the mapped NodeId (in production this overwrites the <c>BadWaitingForInitialData</c> seed that
|
|
||||||
/// <c>OtOpcUaNodeManager.EnsureVariable</c> stamps on a freshly-materialised variable; the recording
|
|
||||||
/// sink does not model that seed, so the faithful assertion available here is that the live value
|
|
||||||
/// lands Good at the same NodeId the materialise created).
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// <b>Seam choices (faithful to the sibling suites).</b> Discovery is driven by Telling the host
|
|
||||||
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> directly, and the polled value by Telling
|
|
||||||
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> directly — exactly the seams the Task-7/8
|
|
||||||
/// tests use (there is no test seam to drive a real <see cref="ITagDiscovery"/> poll loop through a
|
|
||||||
/// child to Connected, and the spawned child is a <see cref="SubscribableStubDriver"/>). The publish
|
|
||||||
/// actor is wired WITHOUT a dbFactory, so the host's apply-time <see cref="OpcUaPublishActor.RebuildAddressSpace"/>
|
|
||||||
/// falls back to a raw <c>sink.RebuildAddressSpace()</c> (no <c>EnsureVariable</c>); this keeps the
|
|
||||||
/// ONLY <c>EnsureVariable</c> traffic on the sink the discovered-node materialise itself, so the
|
|
||||||
/// mapped NodeId is unambiguous. The discovery-injection chain (mapper → applier → sink + routing
|
|
||||||
/// map) is fully real.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
[Trait("Category", "Unit")]
|
|
||||||
public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
|
||||||
{
|
|
||||||
private static readonly NodeId TestNode = NodeId.Parse("disc-e2e-node");
|
|
||||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
||||||
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
|
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
||||||
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
// The FixedTree node the driver "discovers": FOCAS/<deviceHost>/Identity/SeriesNumber, a String value,
|
|
||||||
// whose FullReference differs from any authored tag so the mapper keeps it (does not shadow an authored
|
|
||||||
// node). The single device-host folder collapses, so it materialises at EQ-1/FOCAS/Identity/SeriesNumber.
|
|
||||||
private const string FixedTreeRef = "10.0.0.5:8193/Identity/SeriesNumber";
|
|
||||||
private const string FixedTreeDisplayName = "SeriesNumber";
|
|
||||||
|
|
||||||
// The DETERMINISTIC NodeId the chain must place the FixedTree node at: EQ-1 (the bound equipment root) +
|
|
||||||
// the COLLAPSED folder path. The mapper's device-folder collapse drops the single shared device-host
|
|
||||||
// segment ("10.0.0.5:8193"), so FolderPathSegments ["FOCAS","10.0.0.5:8193","Identity"] + browse
|
|
||||||
// "SeriesNumber" → "EQ-1/FOCAS/Identity/SeriesNumber" (per EquipmentNodeIds.Variable). Asserting this
|
|
||||||
// EXACT NodeId closes the loop on the collapse rule — a prefix/StartsWith check would still pass if the
|
|
||||||
// collapse broke (e.g. "EQ-1/FOCAS/10.0.0.5:8193/Identity/SeriesNumber").
|
|
||||||
private const string ExpectedFixedTreeNodeId = "EQ-1/FOCAS/Identity/SeriesNumber";
|
|
||||||
|
|
||||||
private static DiscoveredNode[] FixedTreeNodes() => new[]
|
|
||||||
{
|
|
||||||
new DiscoveredNode(
|
|
||||||
FolderPathSegments: new[] { "FOCAS", "10.0.0.5:8193", "Identity" },
|
|
||||||
BrowseName: "SeriesNumber",
|
|
||||||
DisplayName: FixedTreeDisplayName,
|
|
||||||
FullReference: FixedTreeRef,
|
|
||||||
DataType: DriverDataType.String,
|
|
||||||
IsArray: false,
|
|
||||||
ArrayDim: null,
|
|
||||||
Writable: false,
|
|
||||||
IsHistorized: false),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// End-to-end #1: the discovered FixedTree node appears at the equipment AND a polled value flows
|
|
||||||
/// Good. Drives the real host (deployment applied, real child spawned, discovery reported) wired to a
|
|
||||||
/// real publish actor + real applier + recording sink, then asserts:
|
|
||||||
/// (a) the sink recorded an <c>EnsureVariable</c> for the FixedTree node under EQ-1 (materialised
|
|
||||||
/// through the REAL applier — the node now exists in the served address space), with a
|
|
||||||
/// <c>RaiseNodesAddedModelChange</c> under EQ-1 so connected clients refresh;
|
|
||||||
/// (b) after an <see cref="DriverInstanceActor.AttributeValuePublished"/> for the FixedTree ref, the
|
|
||||||
/// sink recorded a <c>WriteValue</c> at THAT SAME NodeId carrying the value with
|
|
||||||
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
|
|
||||||
/// overwrote the BadWaitingForInitialData seed.
|
|
||||||
/// </summary>
|
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
|
||||||
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
|
|
||||||
{
|
|
||||||
var db = NewInMemoryDbFactory();
|
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
|
||||||
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
|
||||||
|
|
||||||
var (host, sink, _) = SpawnHostWithRealPublishActor(db, deploymentId);
|
|
||||||
|
|
||||||
// Driver reports its captured FixedTree (the faithful Task-7/8 seam).
|
|
||||||
host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
|
|
||||||
|
|
||||||
// (a) The discovered variable was materialised through the REAL applier onto the sink, at the EXACT
|
|
||||||
// collapsed NodeId under the bound equipment root (proves the mapper's device-folder collapse).
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
var v = sink.Variables.SingleOrDefault(x => x.DisplayName == FixedTreeDisplayName);
|
|
||||||
v.NodeId.ShouldBe(ExpectedFixedTreeNodeId); // EnsureVariable at the exact collapsed NodeId under EQ-1
|
|
||||||
v.DataType.ShouldBe("String"); // mapper carried the driver type through to the sink
|
|
||||||
v.Writable.ShouldBeFalse(); // discovered nodes are read-only
|
|
||||||
sink.ModelChanges.ShouldContain("EQ-1"); // NodeAdded announced under the equipment
|
|
||||||
}, duration: Timeout);
|
|
||||||
|
|
||||||
// (b) A value published for the FixedTree ref routes to THAT exact NodeId and lands Good — the live
|
|
||||||
// value flowed end-to-end (host routing map → publish actor → applier-backing sink WriteValue).
|
|
||||||
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-12345", OpcUaQuality.Good, Ts));
|
|
||||||
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
var write = sink.Values.SingleOrDefault(x => x.NodeId == ExpectedFixedTreeNodeId);
|
|
||||||
write.NodeId.ShouldBe(ExpectedFixedTreeNodeId);
|
|
||||||
write.Value.ShouldBe("SN-12345");
|
|
||||||
write.Quality.ShouldBe(OpcUaQuality.Good);
|
|
||||||
write.Ts.ShouldBe(Ts);
|
|
||||||
}, duration: Timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// End-to-end #2 (Task 8 survival): the injected FixedTree node + its live-value route SURVIVE a
|
|
||||||
/// redeploy. After the first injection materialises + a value flows Good, a SECOND deployment (new
|
|
||||||
/// revision, same d1 → EQ-1 binding) re-runs <c>PushDesiredSubscriptions</c> — which clears the
|
|
||||||
/// routing maps and re-pushes an authored-only subscription set; the Task-8 tail re-apply re-grafts
|
|
||||||
/// the cached discovered plan. Asserts the sink records the FixedTree <c>EnsureVariable</c> AGAIN
|
|
||||||
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
|
|
||||||
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
|
|
||||||
/// </summary>
|
|
||||||
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
|
|
||||||
public void Discovered_node_and_value_survive_a_redeploy()
|
|
||||||
{
|
|
||||||
var db = NewInMemoryDbFactory();
|
|
||||||
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
|
|
||||||
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
|
||||||
|
|
||||||
var (host, sink, coordinator) = SpawnHostWithRealPublishActor(db, deploymentId);
|
|
||||||
|
|
||||||
host.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", FixedTreeNodes()));
|
|
||||||
|
|
||||||
// First injection: the FixedTree node materialises at the EXACT collapsed NodeId under EQ-1.
|
|
||||||
AwaitAssert(
|
|
||||||
() => sink.Variables.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && x.DisplayName == FixedTreeDisplayName),
|
|
||||||
duration: Timeout);
|
|
||||||
|
|
||||||
// First value flows Good (pre-redeploy baseline).
|
|
||||||
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-AAA", OpcUaQuality.Good, Ts));
|
|
||||||
AwaitAssert(
|
|
||||||
() => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-AAA") && x.Quality == OpcUaQuality.Good),
|
|
||||||
duration: Timeout);
|
|
||||||
|
|
||||||
var ensureVarCountBefore = sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId);
|
|
||||||
|
|
||||||
// Apply a SECOND deployment (new revision, SAME d1 → EQ-1 binding) — re-runs PushDesiredSubscriptions
|
|
||||||
// (clears + rebuilds the routing maps) then the Task-8 tail re-applies the cached discovered plan.
|
|
||||||
var deploymentId2 = SeedDeploymentWithEquipmentTags(db, RevB,
|
|
||||||
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
|
|
||||||
host.Tell(new DispatchDeployment(deploymentId2, RevB, CorrelationId.NewId()));
|
|
||||||
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
||||||
|
|
||||||
// (a) The cached discovered plan was RE-MATERIALISED at the SAME exact NodeId after the redeploy rebuild.
|
|
||||||
AwaitAssert(
|
|
||||||
() => sink.Variables.Count(x => x.NodeId == ExpectedFixedTreeNodeId).ShouldBeGreaterThan(ensureVarCountBefore),
|
|
||||||
duration: Timeout);
|
|
||||||
|
|
||||||
// (b) A value published AFTER the redeploy STILL routes to the exact NodeId and lands Good — the
|
|
||||||
// live-value routing map was rebuilt by the re-apply (not lost when PushDesiredSubscriptions cleared it).
|
|
||||||
var tsAfter = Ts.AddSeconds(5);
|
|
||||||
host.Tell(new DriverInstanceActor.AttributeValuePublished("d1", FixedTreeRef, "SN-BBB", OpcUaQuality.Good, tsAfter));
|
|
||||||
AwaitAssert(
|
|
||||||
() => sink.Values.ShouldContain(x => x.NodeId == ExpectedFixedTreeNodeId && Equals(x.Value, "SN-BBB") && x.Quality == OpcUaQuality.Good),
|
|
||||||
duration: Timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Spawns the real chain — recording sink → real <see cref="AddressSpaceApplier"/> → real
|
|
||||||
/// <see cref="OpcUaPublishActor"/> (the host's <c>opcUaPublishActor</c> seam) → real
|
|
||||||
/// <see cref="DriverHostActor"/> backed by a <see cref="SubscribableStubDriver"/> — dispatches the
|
|
||||||
/// deployment, and waits for the Applied ACK so <c>_lastComposition</c> + the live child + the initial
|
|
||||||
/// subscribe pass have completed before discovery is injected. The publish actor is wired with the
|
|
||||||
/// applier but NO dbFactory, so its apply-time RebuildAddressSpace is a raw sink rebuild (no EnsureVariable)
|
|
||||||
/// and the only EnsureVariable traffic is the discovered-node materialise itself.</summary>
|
|
||||||
private (IActorRef Host, RecordingSink Sink, Akka.TestKit.TestProbe Coordinator) SpawnHostWithRealPublishActor(
|
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
|
||||||
{
|
|
||||||
var coordinator = CreateTestProbe();
|
|
||||||
var vtHost = CreateTestProbe();
|
|
||||||
|
|
||||||
var sink = new RecordingSink();
|
|
||||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
||||||
var publish = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
|
||||||
|
|
||||||
var host = Sys.ActorOf(DriverHostActor.Props(
|
|
||||||
db, TestNode, coordinator.Ref,
|
|
||||||
driverFactory: new SubscribingDriverFactory("Modbus"),
|
|
||||||
localRoles: new HashSet<string> { "driver" },
|
|
||||||
opcUaPublishActor: publish,
|
|
||||||
virtualTagHostOverride: vtHost.Ref));
|
|
||||||
|
|
||||||
host.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
||||||
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
||||||
|
|
||||||
return (host, sink, coordinator);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Seeds a Sealed deployment whose artifact carries the minimal arrays needed to project
|
|
||||||
/// equipment tags + a real (non-stubbed) <see cref="DriverInstanceActor"/> child for each driver
|
|
||||||
/// (mirrors <c>DriverHostActorDiscoveryTests.SeedDeploymentWithEquipmentTags</c>). An authored value tag
|
|
||||||
/// both sets <c>_lastComposition</c> and binds the driver → equipment (the only way the host resolves the
|
|
||||||
/// equipment a discovered node grafts under).</summary>
|
|
||||||
private static DeploymentId SeedDeploymentWithEquipmentTags(
|
|
||||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
|
||||||
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
|
|
||||||
{
|
|
||||||
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
|
|
||||||
|
|
||||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
|
||||||
{
|
|
||||||
Namespaces = new[]
|
|
||||||
{
|
|
||||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
|
||||||
},
|
|
||||||
DriverInstances = driverIds.Select(d => new
|
|
||||||
{
|
|
||||||
DriverInstanceRowId = Guid.NewGuid(),
|
|
||||||
DriverInstanceId = d,
|
|
||||||
Name = d,
|
|
||||||
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
|
||||||
Enabled = true,
|
|
||||||
DriverConfig = "{}",
|
|
||||||
NamespaceId = "ns-eq",
|
|
||||||
}).ToArray(),
|
|
||||||
Tags = tags.Select((t, i) => new
|
|
||||||
{
|
|
||||||
TagId = $"tag-{i}",
|
|
||||||
EquipmentId = t.Equip,
|
|
||||||
DriverInstanceId = t.Driver,
|
|
||||||
Name = t.Name,
|
|
||||||
FolderPath = t.Folder,
|
|
||||||
DataType = "Double",
|
|
||||||
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
|
|
||||||
}).ToArray(),
|
|
||||||
});
|
|
||||||
|
|
||||||
var id = DeploymentId.NewId();
|
|
||||||
using var ctx = db.CreateDbContext();
|
|
||||||
ctx.Deployments.Add(new Deployment
|
|
||||||
{
|
|
||||||
DeploymentId = id.Value,
|
|
||||||
RevisionHash = rev.Value,
|
|
||||||
Status = DeploymentStatus.Sealed,
|
|
||||||
CreatedBy = "test",
|
|
||||||
SealedAtUtc = DateTime.UtcNow,
|
|
||||||
ArtifactBlob = artifact,
|
|
||||||
});
|
|
||||||
ctx.SaveChanges();
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Factory producing a single shared <see cref="SubscribableStubDriver"/> for the supported
|
|
||||||
/// type, so a real (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the driver and
|
|
||||||
/// the host's subscribe path is exercised (mirrors
|
|
||||||
/// <c>DriverHostActorDiscoveryTests.SubscribingDriverFactory</c>).</summary>
|
|
||||||
private sealed class SubscribingDriverFactory : IDriverFactory
|
|
||||||
{
|
|
||||||
private readonly string _supportedType;
|
|
||||||
private readonly SubscribableStubDriver _driver = new();
|
|
||||||
public SubscribingDriverFactory(string supportedType) { _supportedType = supportedType; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
|
|
||||||
string.Equals(driverType, _supportedType, StringComparison.Ordinal) ? _driver : null;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Recording <see cref="IOpcUaAddressSpaceSink"/> — captures the EnsureFolder / EnsureVariable /
|
|
||||||
/// WriteValue / RaiseNodesAddedModelChange calls the real applier + publish actor drive, so the test can
|
|
||||||
/// assert the discovered node was materialised and a value landed Good at its NodeId. Thread-safe (the
|
|
||||||
/// publish actor runs on an Akka dispatcher thread, the test asserts from the test thread).</summary>
|
|
||||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
|
||||||
{
|
|
||||||
private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName)> _folders = new();
|
|
||||||
private readonly ConcurrentQueue<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> _variables = new();
|
|
||||||
private readonly ConcurrentQueue<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> _values = new();
|
|
||||||
private readonly ConcurrentQueue<string> _modelChanges = new();
|
|
||||||
|
|
||||||
/// <summary>Gets a snapshot of the recorded EnsureFolder calls.</summary>
|
|
||||||
public List<(string NodeId, string? ParentNodeId, string DisplayName)> Folders => _folders.ToList();
|
|
||||||
/// <summary>Gets a snapshot of the recorded EnsureVariable calls.</summary>
|
|
||||||
public List<(string NodeId, string? ParentNodeId, string DisplayName, string DataType, bool Writable)> Variables => _variables.ToList();
|
|
||||||
/// <summary>Gets a snapshot of the recorded WriteValue calls.</summary>
|
|
||||||
public List<(string NodeId, object? Value, OpcUaQuality Quality, DateTime Ts)> Values => _values.ToList();
|
|
||||||
/// <summary>Gets a snapshot of the recorded RaiseNodesAddedModelChange announcements.</summary>
|
|
||||||
public List<string> ModelChanges => _modelChanges.ToList();
|
|
||||||
/// <summary>Gets the count of raw RebuildAddressSpace calls (apply-time rebuild fallback).</summary>
|
|
||||||
public int RebuildCalls;
|
|
||||||
|
|
||||||
/// <summary>Records a live-value write.</summary>
|
|
||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
|
||||||
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
|
|
||||||
|
|
||||||
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
|
|
||||||
public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
|
|
||||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
|
||||||
|
|
||||||
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
|
|
||||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
|
||||||
|
|
||||||
/// <summary>Records an EnsureFolder call.</summary>
|
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
|
||||||
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
|
|
||||||
|
|
||||||
/// <summary>Records an EnsureVariable call.</summary>
|
|
||||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
|
|
||||||
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
|
|
||||||
|
|
||||||
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
|
|
||||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
|
||||||
|
|
||||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
|
||||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
|
|
||||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
|
||||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1327
File diff suppressed because it is too large
Load Diff
+17
-7
@@ -78,10 +78,17 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas
|
|||||||
factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef);
|
factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Positive control for the subscription half: a READABLE artifact that keeps the driver but
|
/// <summary>
|
||||||
/// drops its last tag genuinely does clear the live subscription — the child receives an empty desired
|
/// Positive control for the subscription half: a READABLE artifact that drops the driver's last tag
|
||||||
/// set and unsubscribes. This proves both that the unsubscribe is observable through this factory and
|
/// genuinely does tear the live subscription down, and does so well within
|
||||||
/// that it lands well within <see cref="SettleWindow"/>, so the absence asserted above is real.</summary>
|
/// <see cref="SettleWindow"/> — without this, the absence asserted above would pass on a race.
|
||||||
|
/// <para><b>The observable changed with #516.</b> Dropping a tag changes the driver's
|
||||||
|
/// <c>DriverConfig</c>, which used to be an in-place delta: the child received an empty desired set
|
||||||
|
/// and called <c>UnsubscribeAsync</c>. A config change is now a stop + respawn, so the old child is
|
||||||
|
/// STOPPED (taking its subscription with it via <c>ShutdownAsync</c>) and a fresh one spawns with
|
||||||
|
/// nothing to subscribe to. The teardown is therefore observed as a shutdown, not an unsubscribe —
|
||||||
|
/// the same event, reached by a different route.</para>
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dropping_a_drivers_last_tag_does_clear_its_subscription()
|
public void Dropping_a_drivers_last_tag_does_clear_its_subscription()
|
||||||
{
|
{
|
||||||
@@ -90,13 +97,13 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas
|
|||||||
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
|
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
|
||||||
|
|
||||||
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
|
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
|
||||||
factory.UnsubscribeCount.ShouldBe(0);
|
factory.ShutdownCount.ShouldBe(0);
|
||||||
|
|
||||||
actor.Tell(new DispatchDeployment(SeedTaglessDriverDeployment(db, RevB), RevB, CorrelationId.NewId()));
|
actor.Tell(new DispatchDeployment(SeedTaglessDriverDeployment(db, RevB), RevB, CorrelationId.NewId()));
|
||||||
coordinator.ExpectMsg<ApplyAck>(Timeout);
|
coordinator.ExpectMsg<ApplyAck>(Timeout);
|
||||||
|
|
||||||
AwaitAssert(() => factory.UnsubscribeCount.ShouldBe(1), duration: SettleWindow);
|
AwaitAssert(() => factory.ShutdownCount.ShouldBeGreaterThanOrEqualTo(1), duration: SettleWindow);
|
||||||
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // the driver itself stayed
|
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // respawned under the same id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Positive control: the guard keys on "the read gave us nothing", not on "the new config has
|
/// <summary>Positive control: the guard keys on "the read gave us nothing", not on "the new config has
|
||||||
@@ -318,6 +325,9 @@ public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBas
|
|||||||
/// <summary>Number of <c>UnsubscribeAsync</c> calls — non-zero means a live handle was torn down.</summary>
|
/// <summary>Number of <c>UnsubscribeAsync</c> calls — non-zero means a live handle was torn down.</summary>
|
||||||
public int UnsubscribeCount => Volatile.Read(ref _driver.UnsubscribeCount);
|
public int UnsubscribeCount => Volatile.Read(ref _driver.UnsubscribeCount);
|
||||||
|
|
||||||
|
/// <summary>Number of <c>ShutdownAsync</c> calls — non-zero means a driver child was stopped.</summary>
|
||||||
|
public int ShutdownCount => Volatile.Read(ref _driver.ShutdownCount);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
|
||||||
string.Equals(driverType, supportedType, StringComparison.Ordinal) ? _driver : null;
|
string.Equals(driverType, supportedType, StringComparison.Ordinal) ? _driver : null;
|
||||||
|
|||||||
-583
@@ -1,583 +0,0 @@
|
|||||||
using Akka.Actor;
|
|
||||||
using Shouldly;
|
|
||||||
using Xunit;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Covers the bounded post-connect re-discovery loop: when an <see cref="ITagDiscovery"/> driver
|
|
||||||
/// reaches Connected, <see cref="DriverInstanceActor"/> runs repeated discovery passes (FOCAS-style:
|
|
||||||
/// the FixedTree is suppressed until the driver's cache populates ~0–2s after connect) and ships each
|
|
||||||
/// pass's captured nodes to its parent as <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The
|
|
||||||
/// loop STOPS once the non-empty discovered set stabilises (or the attempt cap is hit) — it must not
|
|
||||||
/// spin forever. A driver that does not implement <see cref="ITagDiscovery"/> produces no passes at all.
|
|
||||||
/// </summary>
|
|
||||||
[Trait("Category", "Unit")]
|
|
||||||
public sealed class DriverInstanceActorDiscoveryTests : RuntimeActorTestBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A discoverable driver whose first two passes yield nothing (cache still warming) and whose third
|
|
||||||
/// pass onward yields a stable 3-node set: the actor ships every pass, then STOPS once the non-empty
|
|
||||||
/// set repeats. The final <see cref="DriverInstanceActor.DiscoveredNodesReady"/> carries the 3 nodes
|
|
||||||
/// and no further passes arrive — proving the loop is bounded.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Discovery_retries_until_set_stabilises_then_stops()
|
|
||||||
{
|
|
||||||
var driver = new DiscoverableStubDriver();
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
// Tiny interval so the bounded retry runs in well under a second (no real-time waits).
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
// Drive Connecting → Connected; the Connected entry kicks discovery.
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
// Each discovery pass publishes one DiscoveredNodesReady. The fake stabilises after pass 4
|
|
||||||
// (passes: 0,0,3,3), so exactly 4 messages arrive, then the stream stops.
|
|
||||||
var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
|
|
||||||
for (var i = 0; i < 4; i++)
|
|
||||||
msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));
|
|
||||||
|
|
||||||
// The loop must STOP once the non-empty set has stabilised — no fifth pass.
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
|
|
||||||
// Early passes were empty (FixedTree cache still populating).
|
|
||||||
msgs[0].Nodes.Count.ShouldBe(0);
|
|
||||||
msgs[1].Nodes.Count.ShouldBe(0);
|
|
||||||
// The set then appears and stabilises at 3 nodes.
|
|
||||||
msgs[2].Nodes.Count.ShouldBe(3);
|
|
||||||
var final = msgs[^1];
|
|
||||||
final.Nodes.Count.ShouldBe(3);
|
|
||||||
final.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
|
|
||||||
final.Nodes.Select(n => n.FullReference).ShouldBe(new[] { "m.fixed.v0", "m.fixed.v1", "m.fixed.v2" });
|
|
||||||
|
|
||||||
// The driver was asked exactly as many times as messages published — no extra zombie pass.
|
|
||||||
driver.DiscoverCount.ShouldBe(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A driver that does not implement <see cref="ITagDiscovery"/> produces no discovery passes —
|
|
||||||
/// the Connected entry's discovery kick is a no-op, so the parent receives no
|
|
||||||
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.</summary>
|
|
||||||
[Fact]
|
|
||||||
public void Driver_without_ITagDiscovery_produces_no_discovery()
|
|
||||||
{
|
|
||||||
var driver = new SubscribableStubDriver(); // IDriver + ISubscribable, NOT ITagDiscovery
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
||||||
|
|
||||||
// No discovery capability ⇒ never any DiscoveredNodesReady to the parent.
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Discovery RE-RUNS on every return to Connected: after the initial discovery settles, a
|
|
||||||
/// <see cref="DriverInstanceActor.ForceReconnect"/> drives the actor through Reconnecting and
|
|
||||||
/// back to Connected (via the auto-retry timer, the same path the existing reconnect tests use),
|
|
||||||
/// and a fresh bounded discovery loop fires — keeping the injected tree current if the backend's
|
|
||||||
/// capabilities changed across the reconnect. The new init bumps the generation, so any
|
|
||||||
/// pre-reconnect tick is discarded by the generation guard (the initial loop has already settled
|
|
||||||
/// here, so none are in flight).
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Discovery_reruns_after_reconnect()
|
|
||||||
{
|
|
||||||
var driver = new DiscoverableStubDriver();
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
// Tiny reconnect + rediscover intervals so the whole reconnect-then-rediscover cycle runs fast.
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver,
|
|
||||||
reconnectInterval: TimeSpan.FromMilliseconds(50),
|
|
||||||
rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
// Drain the initial settling passes (0,0,3,3) and confirm the first loop stopped.
|
|
||||||
for (var i = 0; i < 4; i++)
|
|
||||||
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
||||||
var passesBeforeReconnect = driver.DiscoverCount; // 4
|
|
||||||
|
|
||||||
// Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
|
|
||||||
actor.Tell(new DriverInstanceActor.ForceReconnect());
|
|
||||||
|
|
||||||
// A fresh discovery pass must arrive after the reconnect — the cache is warm now, so it sees
|
|
||||||
// the stable 3-node set immediately.
|
|
||||||
var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
|
|
||||||
afterReconnect.Nodes.Count.ShouldBe(3);
|
|
||||||
afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
|
|
||||||
|
|
||||||
// The driver was discovered again — proves a fresh loop ran, not a replay of the old one.
|
|
||||||
driver.DiscoverCount.ShouldBeGreaterThan(passesBeforeReconnect);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Regression for the Critical: a driver whose <c>DiscoverAsync</c> completes ASYNCHRONOUSLY (off the
|
|
||||||
/// actor thread) must still ship <see cref="DriverInstanceActor.DiscoveredNodesReady"/>. The handler
|
|
||||||
/// touches <c>Context.Parent</c> + <c>Timers</c> AFTER awaiting discovery; if it awaited with
|
|
||||||
/// <c>ConfigureAwait(false)</c> the continuation would resume off the actor context and those calls
|
|
||||||
/// would throw <c>NotSupportedException("no active ActorContext")</c> — the handler would fault and no
|
|
||||||
/// message would arrive. Synchronous (<c>Task.CompletedTask</c>) stubs mask the bug; this one forces a
|
|
||||||
/// genuine off-context resume (modelled on <c>SubscribableStubDriver.UnsubscribeYields</c>).
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Async_completing_discovery_resumes_on_actor_context_and_publishes()
|
|
||||||
{
|
|
||||||
var driver = new YieldingDiscoverableStubDriver();
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
// With the fix the handler resumes on the actor context, so the publish succeeds and the parent gets
|
|
||||||
// a non-empty set. Without it the handler faults at Context.Parent.Tell and this times out.
|
|
||||||
var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
|
|
||||||
published.Nodes.Count.ShouldBe(3);
|
|
||||||
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Arch-review #10: the SAME async-discovery context-preservation guarantee, but driven through the
|
|
||||||
/// REAL <see cref="CapabilityInvoker"/> — whose Polly pipeline is awaited with
|
|
||||||
/// <c>ConfigureAwait(false)</c> internally. This is the one path the pass-through
|
|
||||||
/// <c>NullDriverCapabilityInvoker</c> (used by the sibling test) cannot cover: it returns the
|
|
||||||
/// call-site task directly, so it never exercises the invoker's internal off-context await. If that
|
|
||||||
/// internal <c>ConfigureAwait(false)</c> leaked to the actor's own <c>await _invoker.ExecuteAsync(…)</c>,
|
|
||||||
/// the post-await <c>Context.Parent.Tell</c> in <c>HandleRediscoverAsync</c> would fault with
|
|
||||||
/// "no active ActorContext" and no message would arrive. Its arrival proves the actor context survives
|
|
||||||
/// the real resilience pipeline.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Async_discovery_through_real_CapabilityInvoker_preserves_actor_context()
|
|
||||||
{
|
|
||||||
var pipelineBuilder = new DriverResiliencePipelineBuilder(); // real Polly pipeline, tier-A defaults
|
|
||||||
var invoker = new CapabilityInvoker(
|
|
||||||
pipelineBuilder,
|
|
||||||
driverInstanceId: "yielding-discoverable",
|
|
||||||
optionsAccessor: () => new DriverResilienceOptions { Tier = DriverTier.A },
|
|
||||||
driverType: "Stub");
|
|
||||||
var driver = new YieldingDiscoverableStubDriver();
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), invoker: invoker));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
var published = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(5));
|
|
||||||
published.Nodes.Count.ShouldBe(3);
|
|
||||||
published.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The attempt cap bounds a discovered set that never stabilises: a driver whose set keeps GROWING
|
|
||||||
/// (1,2,3,…) never repeats its signature, so the loop is stopped only by
|
|
||||||
/// <c>rediscoverMaxAttempts</c>. With a cap of 3, exactly 3 passes are published, then the stream stops.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Never_stabilising_discovery_is_bounded_by_the_attempt_cap()
|
|
||||||
{
|
|
||||||
var driver = new GrowingDiscoverableStubDriver();
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20), rediscoverMaxAttempts: 3));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
var msgs = new List<DriverInstanceActor.DiscoveredNodesReady>();
|
|
||||||
for (var i = 0; i < 3; i++)
|
|
||||||
msgs.Add(parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)));
|
|
||||||
|
|
||||||
// Cap reached — no fourth pass even though the set never stabilised.
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
|
|
||||||
// The set genuinely kept growing across the capped passes (1,2,3 nodes).
|
|
||||||
msgs.Select(m => m.Nodes.Count).ShouldBe(new[] { 1, 2, 3 });
|
|
||||||
driver.DiscoverCount.ShouldBe(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
|
|
||||||
/// <see cref="DiscoveryRediscoverPolicy.Never"/> opts out of post-connect discovery entirely: the
|
|
||||||
/// Connected entry's discovery kick returns before scheduling the first tick, so the driver is never
|
|
||||||
/// asked to discover and the parent receives no <see cref="DriverInstanceActor.DiscoveredNodesReady"/>.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Discovery_policy_Never_runs_no_passes_and_publishes_nothing()
|
|
||||||
{
|
|
||||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
// Connect happened (the discovery decision is made on the Connected entry)...
|
|
||||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
||||||
|
|
||||||
// ...but policy=Never ⇒ no discovery pass is ever run and nothing is published.
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
driver.DiscoverCount.ShouldBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A driver whose <see cref="ITagDiscovery.RediscoverPolicy"/> is
|
|
||||||
/// <see cref="DiscoveryRediscoverPolicy.Once"/> runs EXACTLY one post-connect pass even when its
|
|
||||||
/// discovered set would keep growing forever — under <c>UntilStable</c> the never-repeating signature
|
|
||||||
/// would retry to the attempt cap. Exactly one <see cref="DriverInstanceActor.DiscoveredNodesReady"/>
|
|
||||||
/// is published and no further <c>RediscoverTick</c> is scheduled.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Discovery_policy_Once_publishes_exactly_one_pass_even_when_set_keeps_growing()
|
|
||||||
{
|
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
// Exactly one pass is published (the first, growing set → 1 node)...
|
|
||||||
var only = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
|
|
||||||
only.Nodes.Count.ShouldBe(1);
|
|
||||||
only.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
|
|
||||||
|
|
||||||
// ...and NO second tick is scheduled, even though the set would keep growing under UntilStable.
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
driver.DiscoverCount.ShouldBe(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="DiscoveryRediscoverPolicy.Once"/> means one pass PER (re)connect cycle — not one pass
|
|
||||||
/// ever. After the initial single pass settles, a <see cref="DriverInstanceActor.ForceReconnect"/>
|
|
||||||
/// drives the actor through Reconnecting and back to Connected (via the auto retry-connect timer), and
|
|
||||||
/// <c>StartDiscovery</c> re-kicks discovery — which must run EXACTLY ONE more pass, not the full attempt
|
|
||||||
/// cap. Uses the ever-growing fake with a small cap (3): under a (wrong) policy-ignoring loop the
|
|
||||||
/// never-stabilising set would publish 3 passes per connect, so a single post-reconnect pass proves
|
|
||||||
/// <c>Once</c> is honoured on the reconnect path too. Guards the exact StartDiscovery-on-reconnect path
|
|
||||||
/// the follow-on TriggerRediscovery task touches.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Discovery_policy_Once_reruns_one_pass_on_reconnect()
|
|
||||||
{
|
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
// Small reconnect + rediscover intervals so the cycle runs fast; cap 3 so a (wrong) full loop is
|
|
||||||
// visibly more than the one pass Once must run per (re)connect.
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver,
|
|
||||||
reconnectInterval: TimeSpan.FromMilliseconds(50),
|
|
||||||
rediscoverInterval: TimeSpan.FromMilliseconds(20),
|
|
||||||
rediscoverMaxAttempts: 3));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
// Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then no more.
|
|
||||||
var first = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
|
|
||||||
first.Nodes.Count.ShouldBe(1);
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
||||||
driver.DiscoverCount.ShouldBe(1);
|
|
||||||
|
|
||||||
// Force a reconnect: Connected → Reconnecting → (auto retry-connect) → Connected again.
|
|
||||||
actor.Tell(new DriverInstanceActor.ForceReconnect());
|
|
||||||
|
|
||||||
// Once = one pass PER (re)connect: exactly ONE additional pass after the reconnect, NOT the full cap.
|
|
||||||
// The set keeps growing across the reconnect (same driver instance), so this pass yields 2 nodes.
|
|
||||||
var afterReconnect = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(3));
|
|
||||||
afterReconnect.Nodes.Count.ShouldBe(2);
|
|
||||||
afterReconnect.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
|
|
||||||
|
|
||||||
// No further passes — Once did NOT run the attempt cap on reconnect; one pass per connect cycle.
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
driver.DiscoverCount.ShouldBe(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The per-pass discovery timeout is injectable via <see cref="DriverInstanceActor.Props"/> so tests
|
|
||||||
/// can control it without real-time delays. The default constant must be 30 seconds (behaviour-preserving).
|
|
||||||
/// Wiring is verified by constructing via <c>Props</c> with a custom value and confirming the actor starts
|
|
||||||
/// and begins discovery normally.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void Discovery_timeout_default_constant_is_30s_and_Props_accepts_custom_value()
|
|
||||||
{
|
|
||||||
// The constant must exist and preserve the pre-refactor 30 s literal.
|
|
||||||
DriverInstanceActor.DefaultRediscoverDiscoverTimeout.ShouldBe(TimeSpan.FromSeconds(30));
|
|
||||||
|
|
||||||
// Props must accept the new optional parameter — no throw and actor starts normally.
|
|
||||||
var driver = new DiscoverableStubDriver();
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver,
|
|
||||||
rediscoverInterval: TimeSpan.FromMilliseconds(20),
|
|
||||||
rediscoverDiscoverTimeout: TimeSpan.FromSeconds(5)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
// Actor starts and discovery publishes — confirms the custom timeout was wired without error.
|
|
||||||
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> received while Connected re-kicks the
|
|
||||||
/// post-connect discovery loop: after the initial discovery has settled, sending the message drives a
|
|
||||||
/// FRESH discovery pass — the driver's <c>DiscoverCount</c> advances and a new
|
|
||||||
/// <see cref="DriverInstanceActor.DiscoveredNodesReady"/> is published. This is the message
|
|
||||||
/// <see cref="DriverHostActor"/> uses to re-run discovery after rebinding the driver to a new equipment.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void TriggerRediscovery_when_Connected_reruns_discovery()
|
|
||||||
{
|
|
||||||
// Once-policy growing stub: exactly ONE pass per (re)kick, so each StartDiscovery publishes precisely
|
|
||||||
// one DiscoveredNodesReady — the trigger's effect is asserted with a single ExpectMsg + ExpectNoMsg
|
|
||||||
// (no second settling pass to drain, and no stale-tick double pass alongside the fresh one).
|
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
|
|
||||||
// Initial connect: Once ⇒ exactly one pass (growing set → 1 node), then it settles.
|
|
||||||
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
||||||
var passesBeforeTrigger = driver.DiscoverCount; // 1
|
|
||||||
|
|
||||||
// Re-kick discovery via the new message — Once ⇒ exactly one fresh pass (growing set → 2 nodes).
|
|
||||||
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
|
|
||||||
|
|
||||||
var afterTrigger = parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2));
|
|
||||||
afterTrigger.Nodes.Count.ShouldBe(2);
|
|
||||||
afterTrigger.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
|
|
||||||
|
|
||||||
// Exactly one fresh pass ran — DiscoverCount advanced by one and no extra pass arrived.
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
driver.DiscoverCount.ShouldBe(passesBeforeTrigger + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> on a driver whose
|
|
||||||
/// <see cref="ITagDiscovery.RediscoverPolicy"/> is <see cref="DiscoveryRediscoverPolicy.Never"/> does
|
|
||||||
/// NOT re-discover: the handler calls <c>StartDiscovery</c>, which returns early for <c>Never</c>, so
|
|
||||||
/// no pass runs and nothing is published — mirroring the Connected-entry Never opt-out.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void TriggerRediscovery_with_policy_Never_does_not_rediscover()
|
|
||||||
{
|
|
||||||
var driver = new DiscoverableStubDriver(DiscoveryRediscoverPolicy.Never);
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver, rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
|
||||||
|
|
||||||
// Connected, but policy=Never — the trigger is honoured by StartDiscovery's early return.
|
|
||||||
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
|
|
||||||
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
driver.DiscoverCount.ShouldBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <see cref="DriverInstanceActor.TriggerRediscovery"/> received while NOT Connected is a clean silent
|
|
||||||
/// no-op in EVERY non-Connected state: no discovery pass runs, nothing is published, and the actor
|
|
||||||
/// neither crashes nor dies (its eventual (re)connect re-discovers anyway). Covers both <c>Connecting</c>
|
|
||||||
/// (before init completes) and <c>Reconnecting</c> (after a <see cref="DriverInstanceActor.ForceReconnect"/>,
|
|
||||||
/// parked there by a long reconnect interval), with an intervening connect proving the actor is unharmed.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
|
||||||
public void TriggerRediscovery_when_not_Connected_is_a_silent_noop()
|
|
||||||
{
|
|
||||||
// Once-growing stub so a successful connect publishes exactly one pass (clean confirmation of state);
|
|
||||||
// a long reconnect interval so the actor parks in Reconnecting deterministically within the test window.
|
|
||||||
var driver = new GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy.Once);
|
|
||||||
var parent = CreateTestProbe();
|
|
||||||
// #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/
|
|
||||||
// disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it.
|
|
||||||
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
|
|
||||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
|
||||||
driver,
|
|
||||||
reconnectInterval: TimeSpan.FromSeconds(30),
|
|
||||||
rediscoverInterval: TimeSpan.FromMilliseconds(20)));
|
|
||||||
Watch(actor);
|
|
||||||
|
|
||||||
// (1) Connecting: the actor boots into Connecting; send the trigger BEFORE InitializeRequested so it
|
|
||||||
// is handled in that non-Connected state.
|
|
||||||
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
|
|
||||||
|
|
||||||
// No discovery resulted, and the actor is unharmed (no Terminated arrives at the watching test actor).
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
||||||
ExpectNoMsg(TimeSpan.FromMilliseconds(100));
|
|
||||||
driver.DiscoverCount.ShouldBe(0);
|
|
||||||
|
|
||||||
// Drive to Connected (proves the Connecting-state trigger left the actor working); Once ⇒ one pass.
|
|
||||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
||||||
parent.ExpectMsg<DriverInstanceActor.DiscoveredNodesReady>(TimeSpan.FromSeconds(2)).Nodes.Count.ShouldBe(1);
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
|
||||||
var passesAfterConnect = driver.DiscoverCount; // 1
|
|
||||||
|
|
||||||
// (2) Reconnecting: ForceReconnect parks the actor in Reconnecting (30s retry interval ⇒ no auto
|
|
||||||
// reconnect within the window). A TriggerRediscovery here must ALSO be a clean silent no-op. Both
|
|
||||||
// messages are processed in order, so the trigger is handled while Reconnecting.
|
|
||||||
actor.Tell(new DriverInstanceActor.ForceReconnect());
|
|
||||||
actor.Tell(new DriverInstanceActor.TriggerRediscovery());
|
|
||||||
|
|
||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
||||||
ExpectNoMsg(TimeSpan.FromMilliseconds(100)); // still alive — no Terminated
|
|
||||||
driver.DiscoverCount.ShouldBe(passesAfterConnect); // no fresh pass while Reconnecting
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A <see cref="StubDriver"/> that also exposes <see cref="ITagDiscovery"/>. Each <c>DiscoverAsync</c>
|
|
||||||
/// pass is counted; passes 1–2 yield nothing (cache warming), passes 3+ yield a stable 3-node set —
|
|
||||||
/// modelling FOCAS, whose FixedTree appears once a few seconds after connect and then stays put.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class DiscoverableStubDriver : StubDriver, ITagDiscovery
|
|
||||||
{
|
|
||||||
private int _passCount;
|
|
||||||
|
|
||||||
/// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
|
|
||||||
/// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
|
|
||||||
/// existing UntilStable tests are unaffected.</summary>
|
|
||||||
public DiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
|
|
||||||
=> RediscoverPolicy = policy;
|
|
||||||
|
|
||||||
/// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
|
|
||||||
public DiscoveryRediscoverPolicy RediscoverPolicy { get; }
|
|
||||||
|
|
||||||
/// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
|
|
||||||
public int DiscoverCount => Volatile.Read(ref _passCount);
|
|
||||||
|
|
||||||
/// <summary>Streams a growing-then-stable node set into the builder (0,0,3,3,…).</summary>
|
|
||||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
|
|
||||||
var count = pass >= 3 ? 3 : 0;
|
|
||||||
var fixedTree = builder.Folder("FixedTree", "FixedTree");
|
|
||||||
for (var i = 0; i < count; i++)
|
|
||||||
{
|
|
||||||
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
|
|
||||||
FullName: $"m.fixed.v{i}",
|
|
||||||
DriverDataType: DriverDataType.Float64,
|
|
||||||
IsArray: false,
|
|
||||||
ArrayDim: null,
|
|
||||||
SecurityClass: SecurityClassification.ViewOnly,
|
|
||||||
IsHistorized: false));
|
|
||||||
}
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A discoverable driver whose <c>DiscoverAsync</c> genuinely SUSPENDS and resumes on a fresh
|
|
||||||
/// thread-pool thread that carries NO Akka actor cell — modelled on
|
|
||||||
/// <c>SubscribableStubDriver.UnsubscribeYields</c>. This forces the actor's <c>await DiscoverAsync(...)</c>
|
|
||||||
/// continuation to resume off-context unless the handler omits <c>ConfigureAwait(false)</c>, so it is a
|
|
||||||
/// deterministic repro of the no-ActorContext race. Returns a stable 3-node set on every pass.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class YieldingDiscoverableStubDriver : StubDriver, ITagDiscovery
|
|
||||||
{
|
|
||||||
/// <summary>Suspends on a TCS completed from a background thread, then streams 3 nodes.</summary>
|
|
||||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
_ = Task.Run(() => tcs.SetResult(), cancellationToken);
|
|
||||||
await tcs.Task.ConfigureAwait(false); // resume on a clean thread-pool thread (no actor cell)
|
|
||||||
var fixedTree = builder.Folder("FixedTree", "FixedTree");
|
|
||||||
for (var i = 0; i < 3; i++)
|
|
||||||
{
|
|
||||||
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
|
|
||||||
FullName: $"m.fixed.v{i}",
|
|
||||||
DriverDataType: DriverDataType.Float64,
|
|
||||||
IsArray: false,
|
|
||||||
ArrayDim: null,
|
|
||||||
SecurityClass: SecurityClassification.ViewOnly,
|
|
||||||
IsHistorized: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A discoverable driver whose set NEVER stabilises: pass N yields N nodes (1,2,3,…), so the
|
|
||||||
/// full-reference signature differs every pass and the loop can only be bounded by the attempt cap.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class GrowingDiscoverableStubDriver : StubDriver, ITagDiscovery
|
|
||||||
{
|
|
||||||
private int _passCount;
|
|
||||||
|
|
||||||
/// <summary>Constructs the fake reporting the given <see cref="DiscoveryRediscoverPolicy"/>;
|
|
||||||
/// defaults to <see cref="DiscoveryRediscoverPolicy.UntilStable"/> (the interface default) so the
|
|
||||||
/// existing attempt-cap test is unaffected. With <see cref="DiscoveryRediscoverPolicy.Once"/> the
|
|
||||||
/// ever-growing set proves the actor stops after a single pass (UntilStable would keep retrying).</summary>
|
|
||||||
public GrowingDiscoverableStubDriver(DiscoveryRediscoverPolicy policy = DiscoveryRediscoverPolicy.UntilStable)
|
|
||||||
=> RediscoverPolicy = policy;
|
|
||||||
|
|
||||||
/// <summary>The post-connect re-discovery policy this fake reports to the actor.</summary>
|
|
||||||
public DiscoveryRediscoverPolicy RediscoverPolicy { get; }
|
|
||||||
|
|
||||||
/// <summary>Number of <see cref="DiscoverAsync"/> passes the actor has driven.</summary>
|
|
||||||
public int DiscoverCount => Volatile.Read(ref _passCount);
|
|
||||||
|
|
||||||
/// <summary>Streams an ever-growing node set (pass N → N nodes).</summary>
|
|
||||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pass = Interlocked.Increment(ref _passCount); // 1-based pass number
|
|
||||||
var fixedTree = builder.Folder("FixedTree", "FixedTree");
|
|
||||||
for (var i = 0; i < pass; i++)
|
|
||||||
{
|
|
||||||
fixedTree.Variable($"v{i}", $"v{i}", new DriverAttributeInfo(
|
|
||||||
FullName: $"m.fixed.v{i}",
|
|
||||||
DriverDataType: DriverDataType.Float64,
|
|
||||||
IsArray: false,
|
|
||||||
ArrayDim: null,
|
|
||||||
SecurityClass: SecurityClassification.ViewOnly,
|
|
||||||
IsHistorized: false));
|
|
||||||
}
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tag-set drift check: periodically re-browse a genuinely-browsable driver and compare what the
|
||||||
|
/// remote offers against what an operator authored.
|
||||||
|
/// <para><b>Why this is gated rather than run for every driver.</b> Modbus, S7, AbLegacy, Sql and Mqtt
|
||||||
|
/// all 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 reports "no drift" forever. A check that cannot fail
|
||||||
|
/// is worse than no check, because it reads as coverage — so the actor runs this only for drivers that
|
||||||
|
/// declare <c>SupportsOnlineDiscovery</c> AND report <c>AuthoredDiscoveryRefs</c>.</para>
|
||||||
|
/// <para>The signal is advisory and shares the Stage-2 surfacing: it raises the same <c>/hosts</c>
|
||||||
|
/// re-browse prompt an <see cref="IRediscoverable"/> raise does. The served address space is never
|
||||||
|
/// rebuilt — raw tags are authored through the <c>/raw</c> browse-commit flow.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DriverInstanceActorDriftCheckTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(60);
|
||||||
|
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
/// <summary>A device offering a tag nothing binds raises the operator prompt, naming what appeared.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_new_ref_on_the_device_raises_the_re_browse_prompt()
|
||||||
|
{
|
||||||
|
var driver = new BrowsableStubDriver(discovered: ["a", "b", "c"], authored: ["a", "b"]);
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var actor = SpawnConnected(driver, publisher);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var signalled = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null);
|
||||||
|
signalled.ShouldNotBeNull();
|
||||||
|
signalled!.RediscoveryReason.ShouldNotBeNull();
|
||||||
|
signalled.RediscoveryReason!.ShouldContain("1 new on device");
|
||||||
|
signalled.RediscoveryReason!.ShouldContain("c");
|
||||||
|
},
|
||||||
|
Budget);
|
||||||
|
|
||||||
|
Sys.Stop(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The gate that makes this feature honest.</b> A driver whose <c>DiscoverAsync</c> replays
|
||||||
|
/// authored config rather than browsing the device declares <c>SupportsOnlineDiscovery == false</c>,
|
||||||
|
/// and must never be drift-checked — for it the comparison could only ever say "no drift".
|
||||||
|
/// <para>Positive control: the driver is handed a set that WOULD read as drift, and the assertion is
|
||||||
|
/// that discovery is never even invoked. Asserting only "no prompt raised" would pass for the wrong
|
||||||
|
/// reason (e.g. if the timer never fired at all).</para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_driver_that_replays_authored_config_is_never_drift_checked()
|
||||||
|
{
|
||||||
|
var driver = new BrowsableStubDriver(
|
||||||
|
discovered: ["a", "b", "c"], authored: ["a"], supportsOnlineDiscovery: false);
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var actor = SpawnConnected(driver, publisher);
|
||||||
|
|
||||||
|
// Long enough for several ticks had the timer been scheduled.
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||||
|
|
||||||
|
driver.DiscoverCount.ShouldBe(
|
||||||
|
0,
|
||||||
|
"a driver that replays authored config was browsed for drift — the comparison is a tautology "
|
||||||
|
+ "for it and would report 'no drift' forever, which reads as coverage");
|
||||||
|
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
|
||||||
|
|
||||||
|
Sys.Stop(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A driver that browses but does NOT report its authored refs opts out — the default. An empty
|
||||||
|
/// collection would mean "nothing authored, everything is new", so null must not be read that way.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_driver_that_reports_no_authored_refs_is_never_drift_checked()
|
||||||
|
{
|
||||||
|
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: null);
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var actor = SpawnConnected(driver, publisher);
|
||||||
|
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
||||||
|
|
||||||
|
driver.DiscoverCount.ShouldBe(0);
|
||||||
|
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
|
||||||
|
|
||||||
|
Sys.Stop(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An UNCHANGED drift is reported once, not re-stamped on every check — a prompt whose timestamp
|
||||||
|
/// keeps moving reads as "it just happened again" rather than "it is still true".
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_unchanged_drift_is_reported_once()
|
||||||
|
{
|
||||||
|
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var actor = SpawnConnected(driver, publisher);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() => publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(1),
|
||||||
|
Budget);
|
||||||
|
|
||||||
|
// Several more ticks pass with the same drift.
|
||||||
|
AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(3), Budget);
|
||||||
|
|
||||||
|
publisher.Published.Count(p => p.RediscoveryNeededUtc is not null).ShouldBe(
|
||||||
|
1,
|
||||||
|
"an unchanged drift was re-announced; the operator prompt would keep re-stamping its timestamp");
|
||||||
|
|
||||||
|
Sys.Stop(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A failed browse is not drift. A device that is merely unreachable already shows on the health
|
||||||
|
/// surface; reporting drift would turn every comms blip into "all your tags vanished".</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_failed_browse_is_not_reported_as_drift()
|
||||||
|
{
|
||||||
|
var driver = new BrowsableStubDriver(discovered: ["a"], authored: ["a", "b"]) { DiscoverThrows = true };
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var actor = SpawnConnected(driver, publisher);
|
||||||
|
|
||||||
|
AwaitAssert(() => driver.DiscoverCount.ShouldBeGreaterThan(1), Budget);
|
||||||
|
|
||||||
|
publisher.Published.ShouldAllBe(
|
||||||
|
p => p.RediscoveryNeededUtc == null,
|
||||||
|
"a browse that threw was treated as drift — an unreachable device would report every authored "
|
||||||
|
+ "tag as vanished");
|
||||||
|
|
||||||
|
Sys.Stop(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drift that resolves — the operator re-browsed and committed — clears the prompt, so the next
|
||||||
|
/// genuine drift re-raises with a fresh timestamp instead of being deduped against the stale one.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Drift_that_resolves_clears_the_prompt()
|
||||||
|
{
|
||||||
|
var driver = new BrowsableStubDriver(discovered: ["a", "b"], authored: ["a"]);
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var actor = SpawnConnected(driver, publisher);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null),
|
||||||
|
Budget);
|
||||||
|
|
||||||
|
// The operator commits the new tag: authored now matches the device.
|
||||||
|
driver.SetAuthored(["a", "b"]);
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() => publisher.Published[^1].RediscoveryNeededUtc.ShouldBeNull(),
|
||||||
|
Budget);
|
||||||
|
|
||||||
|
Sys.Stop(actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActorRef SpawnConnected(IDriver driver, IDriverHealthPublisher publisher)
|
||||||
|
{
|
||||||
|
var parent = CreateTestProbe();
|
||||||
|
parent.IgnoreMessages(_ => true);
|
||||||
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
|
||||||
|
driver, healthPublisher: publisher, driftCheckInterval: Tick));
|
||||||
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Captured health publishes, so a test can read the rediscovery fields.</summary>
|
||||||
|
private sealed record HealthPublish(DateTime? RediscoveryNeededUtc, string? RediscoveryReason);
|
||||||
|
|
||||||
|
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
|
||||||
|
{
|
||||||
|
private readonly List<HealthPublish> _published = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<HealthPublish> Published
|
||||||
|
{
|
||||||
|
get { lock (_published) return _published.ToArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Publish(
|
||||||
|
string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min,
|
||||||
|
DateTime? rediscoveryNeededUtc = null, string? rediscoveryReason = null)
|
||||||
|
{
|
||||||
|
lock (_published) _published.Add(new HealthPublish(rediscoveryNeededUtc, rediscoveryReason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A driver that browses a fake "device". Health is STABLE so the publish dedup genuinely engages —
|
||||||
|
/// the shared <c>StubDriver</c> returns <c>DateTime.UtcNow</c>, which would make every publish look
|
||||||
|
/// changed and let a dedup-related bug pass unnoticed.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class BrowsableStubDriver(
|
||||||
|
string[] discovered,
|
||||||
|
string[]? authored,
|
||||||
|
bool supportsOnlineDiscovery = true) : IDriver, ITagDiscovery
|
||||||
|
{
|
||||||
|
private static readonly DateTime FixedLastRead = new(2026, 7, 28, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
private volatile string[]? _authored = authored;
|
||||||
|
private int _discoverCount;
|
||||||
|
|
||||||
|
/// <summary>Number of discovery passes the actor has driven — the direct read of whether the drift
|
||||||
|
/// check ran at all.</summary>
|
||||||
|
public int DiscoverCount => Volatile.Read(ref _discoverCount);
|
||||||
|
|
||||||
|
/// <summary>When true, every browse throws — models an unreachable device.</summary>
|
||||||
|
public bool DiscoverThrows { get; init; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverInstanceId => "browsable-stub-1";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => "Stub";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool SupportsOnlineDiscovery => supportsOnlineDiscovery;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyCollection<string>? AuthoredDiscoveryRefs => _authored;
|
||||||
|
|
||||||
|
/// <summary>Models the operator committing (or removing) tags between checks.</summary>
|
||||||
|
public void SetAuthored(string[] refs) => _authored = refs;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _discoverCount);
|
||||||
|
if (DiscoverThrows) throw new IOException("device unreachable");
|
||||||
|
|
||||||
|
var folder = builder.Folder("Stub", "Stub");
|
||||||
|
foreach (var r in discovered)
|
||||||
|
{
|
||||||
|
folder.Variable(r, r, new DriverAttributeInfo(
|
||||||
|
FullName: r,
|
||||||
|
DriverDataType: DriverDataType.Float64,
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: SecurityClassification.ViewOnly,
|
||||||
|
IsHistorized: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, null);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the <see cref="IRediscoverable"/> consumer (Gitea #518). Nine drivers raise
|
||||||
|
/// <see cref="IRediscoverable.OnRediscoveryNeeded"/> when they observe that a remote's tag set may have
|
||||||
|
/// changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug
|
||||||
|
/// rebirth. Before this wiring NOTHING in <c>src/</c> subscribed, so every one of those raises went into
|
||||||
|
/// the void.
|
||||||
|
/// <para><b>The signal is advisory.</b> It does not rebuild the served address space: v3 authors raw tags
|
||||||
|
/// explicitly through the <c>/raw</c> browse-commit flow, so a runtime graft would create nodes no
|
||||||
|
/// operator approved. It rides the driver-health snapshot to the AdminUI as a re-browse prompt.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DriverInstanceActorRediscoverySignalTests : RuntimeActorTestBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The load-bearing case: a driver raises rediscovery while otherwise perfectly healthy, and the
|
||||||
|
/// signal reaches the health publisher carrying the driver's reason.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Rediscovery_raise_reaches_the_health_publisher_with_its_reason()
|
||||||
|
{
|
||||||
|
var driver = new RediscoverableStubDriver();
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var parent = CreateTestProbe();
|
||||||
|
parent.IgnoreMessages(_ => true);
|
||||||
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher));
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
driver.RaiseRediscovery("deploy-time-changed");
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var withSignal = publisher.Published.LastOrDefault(p => p.RediscoveryNeededUtc is not null);
|
||||||
|
withSignal.ShouldNotBeNull();
|
||||||
|
withSignal!.RediscoveryReason.ShouldBe("deploy-time-changed");
|
||||||
|
},
|
||||||
|
TimeSpan.FromSeconds(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>Regression guard for the dedup trap.</b> <c>PublishHealthSnapshot</c> suppresses a publish whose
|
||||||
|
/// (state, lastSuccessfulRead, lastError, errorCount) tuple repeats. A rediscovery raise on an
|
||||||
|
/// otherwise-unchanged Healthy driver leaves all four identical, so unless the rediscovery timestamp
|
||||||
|
/// is part of the fingerprint the dedup swallows the very publish carrying the signal and the operator
|
||||||
|
/// never sees the prompt.
|
||||||
|
/// <para>Positive control: the assertion is that the count STRICTLY INCREASES across the raise. An
|
||||||
|
/// "eventually non-null" assertion alone would pass on the warm-up publish and prove nothing. Revert
|
||||||
|
/// <c>_rediscoveryNeededUtc</c> out of the fingerprint tuple and this test must fail.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Rediscovery_raise_is_not_swallowed_by_the_unchanged_health_dedup()
|
||||||
|
{
|
||||||
|
var driver = new RediscoverableStubDriver();
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var parent = CreateTestProbe();
|
||||||
|
parent.IgnoreMessages(_ => true);
|
||||||
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher));
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
// Let the actor settle so nothing else is in flight, then capture the baseline. Everything about
|
||||||
|
// the driver's health is now stable — only the rediscovery flag will change.
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||||
|
var before = publisher.Published.Count;
|
||||||
|
|
||||||
|
driver.RaiseRediscovery("symbol-version-changed");
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() => publisher.Published.Count.ShouldBeGreaterThan(before),
|
||||||
|
TimeSpan.FromSeconds(3));
|
||||||
|
publisher.Published[^1].RediscoveryNeededUtc.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The signal is sticky: a later health publish still carries it. The remote's tag set stayed
|
||||||
|
/// changed, and only an operator re-browsing resolves that — which this actor cannot observe. A
|
||||||
|
/// flag that cleared itself on the next heartbeat would be a prompt the operator could miss entirely.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Rediscovery_flag_persists_on_subsequent_health_publishes()
|
||||||
|
{
|
||||||
|
var driver = new RediscoverableStubDriver();
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var parent = CreateTestProbe();
|
||||||
|
parent.IgnoreMessages(_ => true);
|
||||||
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, healthPublisher: publisher));
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||||
|
driver.RaiseRediscovery("agent-instance-changed");
|
||||||
|
AwaitAssert(
|
||||||
|
() => publisher.Published.Any(p => p.RediscoveryNeededUtc is not null),
|
||||||
|
TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
// Force a further publish by changing something else about the driver's health.
|
||||||
|
driver.SetLastError("transient read failure");
|
||||||
|
AwaitAssert(
|
||||||
|
() => publisher.Published.Any(p => p.LastError == "transient read failure"),
|
||||||
|
TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
var latest = publisher.Published[^1];
|
||||||
|
latest.RediscoveryNeededUtc.ShouldNotBeNull();
|
||||||
|
latest.RediscoveryReason.ShouldBe("agent-instance-changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>Leak guard.</b> The <see cref="IDriver"/> instance can OUTLIVE the actor — the host stops a
|
||||||
|
/// child and respawns another around the same driver object — so a missing <c>-=</c> in
|
||||||
|
/// <c>PostStop</c> accumulates one handler per respawn, each holding a dead <c>Self</c>. Asserts the
|
||||||
|
/// driver's invocation list is empty again after the actor stops.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Stopping_the_actor_detaches_the_rediscovery_handler()
|
||||||
|
{
|
||||||
|
var driver = new RediscoverableStubDriver();
|
||||||
|
var parent = CreateTestProbe();
|
||||||
|
parent.IgnoreMessages(_ => true);
|
||||||
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
|
AwaitAssert(() => driver.SubscriberCount.ShouldBe(1), TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
Watch(actor);
|
||||||
|
actor.Tell(PoisonPill.Instance);
|
||||||
|
ExpectTerminated(actor, TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
driver.SubscriberCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A driver that does NOT implement <see cref="IRediscoverable"/> must publish a null flag —
|
||||||
|
/// the field is absent, not defaulted to "needs re-browse".</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_non_rediscoverable_driver_never_sets_the_flag()
|
||||||
|
{
|
||||||
|
var publisher = new RecordingHealthPublisher();
|
||||||
|
var parent = CreateTestProbe();
|
||||||
|
parent.IgnoreMessages(_ => true);
|
||||||
|
var actor = parent.ChildActorOf(DriverInstanceActor.Props(new StubDriver(), healthPublisher: publisher));
|
||||||
|
|
||||||
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||||
|
AwaitAssert(() => publisher.Published.Count.ShouldBeGreaterThan(0), TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
|
publisher.Published.ShouldAllBe(p => p.RediscoveryNeededUtc == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Captures every health publish so a test can assert on the rediscovery fields.</summary>
|
||||||
|
private sealed record HealthPublish(
|
||||||
|
string ClusterId,
|
||||||
|
string DriverInstanceId,
|
||||||
|
DriverHealth Health,
|
||||||
|
int ErrorCount5Min,
|
||||||
|
DateTime? RediscoveryNeededUtc,
|
||||||
|
string? RediscoveryReason)
|
||||||
|
{
|
||||||
|
public string? LastError => Health.LastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingHealthPublisher : IDriverHealthPublisher
|
||||||
|
{
|
||||||
|
private readonly List<HealthPublish> _published = [];
|
||||||
|
|
||||||
|
/// <summary>Thread-safe snapshot — <c>Publish</c> is called from the actor thread while the test
|
||||||
|
/// asserts from its own.</summary>
|
||||||
|
public IReadOnlyList<HealthPublish> Published
|
||||||
|
{
|
||||||
|
get { lock (_published) return _published.ToArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Publish(
|
||||||
|
string clusterId,
|
||||||
|
string driverInstanceId,
|
||||||
|
DriverHealth health,
|
||||||
|
int errorCount5Min,
|
||||||
|
DateTime? rediscoveryNeededUtc = null,
|
||||||
|
string? rediscoveryReason = null)
|
||||||
|
{
|
||||||
|
lock (_published)
|
||||||
|
{
|
||||||
|
_published.Add(new HealthPublish(
|
||||||
|
clusterId, driverInstanceId, health, errorCount5Min, rediscoveryNeededUtc, rediscoveryReason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A stub driver exposing <see cref="IRediscoverable"/>, a hook to raise it, and the live subscriber
|
||||||
|
/// count (for the detach guard).
|
||||||
|
/// <para><b>Implemented from scratch rather than derived from the shared <c>StubDriver</c> on
|
||||||
|
/// purpose.</b> That one returns <c>GetHealth() => new(Healthy, DateTime.UtcNow, null)</c>, so its
|
||||||
|
/// health fingerprint differs on EVERY call and the dedup under test never engages — which would make
|
||||||
|
/// <see cref="Rediscovery_raise_is_not_swallowed_by_the_unchanged_health_dedup"/> pass vacuously
|
||||||
|
/// whether or not the fix is present. This stub's health is STABLE until a test changes it.</para>
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RediscoverableStubDriver : IDriver, IRediscoverable
|
||||||
|
{
|
||||||
|
private static readonly DateTime FixedLastRead = new(2026, 7, 27, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
private volatile string? _lastError;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverInstanceId => "rediscoverable-stub-1";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DriverType => "Stub";
|
||||||
|
|
||||||
|
/// <summary>Number of live subscribers on <see cref="OnRediscoveryNeeded"/>.</summary>
|
||||||
|
public int SubscriberCount => OnRediscoveryNeeded?.GetInvocationList().Length ?? 0;
|
||||||
|
|
||||||
|
/// <summary>Raises the event exactly as a real driver's watcher does.</summary>
|
||||||
|
public void RaiseRediscovery(string reason)
|
||||||
|
=> OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(reason, ScopeHint: null));
|
||||||
|
|
||||||
|
/// <summary>Changes the reported health so a test can force a publish that is NOT the rediscovery one.</summary>
|
||||||
|
public void SetLastError(string error) => _lastError = error;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, FixedLastRead, _lastError);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -116,7 +116,13 @@ public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActor
|
|||||||
public int Count => Volatile.Read(ref _count);
|
public int Count => Volatile.Read(ref _count);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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)
|
||||||
=> Interlocked.Increment(ref _count);
|
=> Interlocked.Increment(ref _count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,18 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
plan.ToStop.ShouldBeEmpty();
|
plan.ToStop.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that different configuration is routed to ApplyDelta.</summary>
|
/// <summary>
|
||||||
|
/// A changed <c>DriverConfig</c> forces a stop + respawn (#516), NOT an in-place delta.
|
||||||
|
/// <para>This test previously asserted the opposite. The in-place path silently discarded the edit
|
||||||
|
/// on five drivers whose <c>InitializeAsync</c> served constructor-captured options and never read
|
||||||
|
/// the <c>driverConfigJson</c> they were handed — and the deployment still sealed green. Routing
|
||||||
|
/// through the factory makes it the single parse authority, which matters most for the drivers that
|
||||||
|
/// build MORE than options from config (Sql's dialect + connection string, FOCAS's client-factory
|
||||||
|
/// backend) where re-parsing options alone would apply a new tag set against an old connection.</para>
|
||||||
|
/// <para>The accepted cost is a reconnect on every config edit.</para>
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Different_config_routes_to_ApplyDelta()
|
public void Different_config_routes_to_stop_and_respawn()
|
||||||
{
|
{
|
||||||
var current = new Dictionary<string, DriverChildSnapshot>
|
var current = new Dictionary<string, DriverChildSnapshot>
|
||||||
{
|
{
|
||||||
@@ -52,9 +61,11 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
var plan = DriverSpawnPlanner.Compute(current, target);
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a");
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
plan.ToSpawn.ShouldBeEmpty();
|
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
||||||
plan.ToStop.ShouldBeEmpty();
|
plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}");
|
||||||
|
// The whole point: nothing is left on the in-place path that could discard the edit.
|
||||||
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that removed drivers are routed to ToStop.</summary>
|
/// <summary>Verifies that removed drivers are routed to ToStop.</summary>
|
||||||
@@ -166,11 +177,14 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A pure DriverConfig change with an UNCHANGED ResilienceConfig stays an in-place delta (no
|
/// A pure DriverConfig change respawns even when the ResilienceConfig is UNCHANGED.
|
||||||
/// reconnect) — the resilience pipeline is untouched, so there's no reason to respawn.
|
/// <para>This asserted the opposite until #516. The reasoning then was "the resilience pipeline is
|
||||||
|
/// untouched, so there's no reason to respawn" — correct about resilience, wrong about the driver:
|
||||||
|
/// five drivers ignored the config the in-place delta handed them, and the deployment sealed green
|
||||||
|
/// anyway. The reconnect is the accepted price of the edit actually taking effect.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DriverConfig_change_with_unchanged_ResilienceConfig_stays_ApplyDelta()
|
public void DriverConfig_change_respawns_even_when_ResilienceConfig_is_unchanged()
|
||||||
{
|
{
|
||||||
var current = new Dictionary<string, DriverChildSnapshot>
|
var current = new Dictionary<string, DriverChildSnapshot>
|
||||||
{
|
{
|
||||||
@@ -180,8 +194,8 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
var plan = DriverSpawnPlanner.Compute(current, target);
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
plan.ToApplyDelta.Single().DriverInstanceId.ShouldBe("a");
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
plan.ToSpawn.ShouldBeEmpty();
|
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
||||||
plan.ToStop.ShouldBeEmpty();
|
plan.ToApplyDelta.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,17 @@ internal class StubDriver : IDriver
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of <see cref="ShutdownAsync"/> calls — a child actor stopping raises it, so a test
|
||||||
|
/// can observe a driver child being torn down (e.g. by the #516 config-change respawn).</summary>
|
||||||
|
public int ShutdownCount;
|
||||||
|
|
||||||
/// <summary>Shuts down the driver.</summary>
|
/// <summary>Shuts down the driver.</summary>
|
||||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref ShutdownCount);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
/// <summary>Gets the health status of the driver.</summary>
|
/// <summary>Gets the health status of the driver.</summary>
|
||||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
/// <summary>Gets the memory footprint of the driver.</summary>
|
/// <summary>Gets the memory footprint of the driver.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pure half of tag-set drift detection: what the remote offers vs. what an operator authored.
|
||||||
|
/// Kept out of the actor so the comparison rules are testable without an actor system.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TagSetDriftDetectorTests
|
||||||
|
{
|
||||||
|
/// <summary>Matching sets are not drift, and must not raise an operator prompt.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Identical_sets_are_not_drift()
|
||||||
|
{
|
||||||
|
var drift = TagSetDriftDetector.Compare(["a", "b"], ["b", "a"]);
|
||||||
|
|
||||||
|
drift.HasDrift.ShouldBeFalse();
|
||||||
|
drift.Describe().ShouldBe("no drift");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tag the device now offers that nothing binds — the common case after a PLC re-download.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_ref_on_the_device_that_is_not_authored_is_reported_as_appeared()
|
||||||
|
{
|
||||||
|
var drift = TagSetDriftDetector.Compare(["a", "b", "c"], ["a", "b"]);
|
||||||
|
|
||||||
|
drift.Appeared.ShouldBe(["c"]);
|
||||||
|
drift.Vanished.ShouldBeEmpty();
|
||||||
|
drift.HasDrift.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An authored tag the device no longer offers — detectable only for a driver whose discovery
|
||||||
|
/// stream is purely device-derived.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_authored_ref_missing_from_the_device_is_reported_as_vanished()
|
||||||
|
{
|
||||||
|
var drift = TagSetDriftDetector.Compare(["a"], ["a", "b"]);
|
||||||
|
|
||||||
|
drift.Vanished.ShouldBe(["b"]);
|
||||||
|
drift.Appeared.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <b>The honesty guard.</b> When a driver re-emits its authored tags into the discovery stream —
|
||||||
|
/// FOCAS, TwinCAT and AbCip all do — the discovered set always contains the authored set, so a
|
||||||
|
/// vanished tag is structurally invisible. The detector must not compute a misleadingly-empty
|
||||||
|
/// Vanished list; it must flag that the direction is undetectable, and say so in the description.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void When_vanished_is_undetectable_it_is_declared_rather_than_reported_as_empty()
|
||||||
|
{
|
||||||
|
// "b" is authored and absent from the device — but this driver would have re-emitted it, so its
|
||||||
|
// absence here cannot occur in practice and must not be presented as a clean result either way.
|
||||||
|
var drift = TagSetDriftDetector.Compare(["a", "c"], ["a", "b"], detectVanished: false);
|
||||||
|
|
||||||
|
drift.VanishedDetectable.ShouldBeFalse();
|
||||||
|
drift.Vanished.ShouldBeEmpty();
|
||||||
|
drift.Appeared.ShouldBe(["c"]);
|
||||||
|
drift.Describe().ShouldContain(
|
||||||
|
"missing-tag detection unavailable",
|
||||||
|
customMessage:
|
||||||
|
"a driver that cannot see vanished tags must SAY so — otherwise the absence of a 'missing' "
|
||||||
|
+ "clause reads as 'nothing is missing', which is the reassurance this whole exercise removes");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Comparison is ordinal: a device legitimately exposing both <c>Speed</c> and <c>speed</c>
|
||||||
|
/// must not have them collapsed, which would hide a genuinely new tag.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Comparison_is_case_sensitive()
|
||||||
|
{
|
||||||
|
var drift = TagSetDriftDetector.Compare(["Speed", "speed"], ["Speed"]);
|
||||||
|
|
||||||
|
drift.Appeared.ShouldBe(["speed"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The signature identifies a drift so an UNCHANGED one is not re-announced every check.
|
||||||
|
/// Enumeration order must not make an identical drift look new.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Signature_is_stable_across_enumeration_order()
|
||||||
|
{
|
||||||
|
var a = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
|
||||||
|
var b = TagSetDriftDetector.Compare(["z", "y", "x"], ["x"]);
|
||||||
|
|
||||||
|
a.Signature.ShouldBe(b.Signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A different drift must get a different signature, or the second one is never reported.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Signature_changes_when_the_drift_changes()
|
||||||
|
{
|
||||||
|
var a = TagSetDriftDetector.Compare(["x", "y"], ["x"]);
|
||||||
|
var b = TagSetDriftDetector.Compare(["x", "y", "z"], ["x"]);
|
||||||
|
|
||||||
|
a.Signature.ShouldNotBe(b.Signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The description names a couple of refs and then counts, so swapping a whole PLC program
|
||||||
|
/// yields a readable message rather than a wall of tag names.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Describe_samples_rather_than_listing_everything()
|
||||||
|
{
|
||||||
|
var discovered = Enumerable.Range(0, 50).Select(i => $"tag{i:D2}").ToArray();
|
||||||
|
var drift = TagSetDriftDetector.Compare(discovered, []);
|
||||||
|
|
||||||
|
var text = drift.Describe();
|
||||||
|
text.ShouldContain("50 new on device");
|
||||||
|
text.ShouldContain("+48 more");
|
||||||
|
text.Length.ShouldBeLessThan(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,35 +120,6 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
|||||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
|
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> forwards to the
|
|
||||||
/// applier, which drives the sink to ensure the discovered folder + (read-only) variable and announce a
|
|
||||||
/// NodeAdded model-change under the equipment root — proving the message → handler → applier → sink path
|
|
||||||
/// end to end (mirrors the real-applier-over-recording-sink harness in
|
|
||||||
/// <c>OpcUaPublishActorRebuildTests</c>).</summary>
|
|
||||||
[Fact]
|
|
||||||
public void MaterialiseDiscoveredNodes_routes_through_applier_to_sink()
|
|
||||||
{
|
|
||||||
var sink = new RecordingSink();
|
|
||||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
|
||||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
|
||||||
|
|
||||||
var folders = new[] { new DiscoveredFolder("EQ-1/Axes", "EQ-1", "Axes") };
|
|
||||||
var variables = new[]
|
|
||||||
{
|
|
||||||
new DiscoveredVariable("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double",
|
|
||||||
Writable: false, IsArray: false, ArrayLength: null),
|
|
||||||
};
|
|
||||||
|
|
||||||
actor.Tell(new OpcUaPublishActor.MaterialiseDiscoveredNodes("EQ-1", folders, variables));
|
|
||||||
|
|
||||||
AwaitAssert(() =>
|
|
||||||
{
|
|
||||||
sink.Folders.ShouldContain(("EQ-1/Axes", "EQ-1", "Axes"));
|
|
||||||
sink.Variables.ShouldContain(("EQ-1/Axes/X", "EQ-1/Axes", "X", "Double", false));
|
|
||||||
sink.ModelChanges.ShouldContain("EQ-1");
|
|
||||||
}, duration: PresenceBudget);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
|
/// <summary>Verifies that ServiceLevelChanged publishes to IServiceLevelPublisher once per unique level.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()
|
public void ServiceLevelChanged_publishes_to_IServiceLevelPublisher_once_per_unique_level()
|
||||||
|
|||||||
Reference in New Issue
Block a user