Compare commits
2 Commits
fbe393471e
...
fd0bec4ffe
| Author | SHA1 | Date | |
|---|---|---|---|
| fd0bec4ffe | |||
| cf03ca279d |
@@ -57,7 +57,7 @@ only (it owns the shared `TagConfigJson` field readers used by the parser).
|
||||
|
||||
```xml
|
||||
<!-- Directory.Packages.props -->
|
||||
<PackageVersion Include="BACnet" Version="4.0.0" /> <!-- confirm the exact current release when implementing -->
|
||||
<PackageVersion Include="BACnet" Version="4.0.0" /> <!-- placeholder — the exact version + net10 TFM load is item 1 of the P1 first-spike checklist (§10) -->
|
||||
<!-- Driver.Bacnet.csproj -->
|
||||
<PackageReference Include="BACnet" />
|
||||
```
|
||||
@@ -238,6 +238,16 @@ open. That is the documented **P-later graduation** to a bespoke lazy browser:
|
||||
**v1 uses the universal browser. State this clearly in the PR: BACnet ships with `SupportsOnlineDiscovery
|
||||
= true` and zero browser code.**
|
||||
|
||||
**Deployment note — AdminUI node network posture.** The universal browser runs its
|
||||
`InitializeAsync → DiscoverAsync` capture **in the AdminUI process**, so the **AdminUI node itself needs
|
||||
UDP reachability to the BACnet network** for the picker to browse — this is the same in-process posture as
|
||||
the existing OpcUaClient/Galaxy browsers, but BACnet is the **first UDP/broadcast** one, so firewall/VLAN
|
||||
rules that only whitelist the runtime driver node will silently break browse. When the AdminUI node is
|
||||
off-segment, its browse capture registers as a **second foreign device** with the site BBMD (in addition
|
||||
to the runtime driver's standing registration) for the duration of the capture — some BBMDs cap their
|
||||
foreign-device-table entries, so size the FD table (and any per-source firewall rules) for **both**
|
||||
registrations, not one.
|
||||
|
||||
---
|
||||
|
||||
## 5. TagConfig + driver-config JSON
|
||||
@@ -475,8 +485,10 @@ live-gate discipline:
|
||||
route** (and bridged docker networks don't forward subnet broadcasts into containers either). So the
|
||||
suite as run from the dev machine has exactly two working paths: (a) **directed unicast Who-Is** at
|
||||
the fixture's host IP:port (host-mode `bacserv` answers a unicast Who-Is with a unicast I-Am to the
|
||||
request's source address — verify this against the pinned `bacserv` build early, since spec-strict
|
||||
stacks may broadcast the I-Am, which would never arrive); and (b) the **foreign-device/BBMD leg** —
|
||||
request's source address — **verifying this against the pinned `bacserv`/`BasicServer` builds is item 3
|
||||
of the P1 first-spike checklist (§10)**, since spec-strict stacks may broadcast the I-Am, which would
|
||||
never arrive; if they broadcast, this leg collapses to the BBMD/foreign-device leg below); and (b) the
|
||||
**foreign-device/BBMD leg** —
|
||||
register with a BBMD container on the docker host; BVLC `Forwarded-NPDU` relay is *unicast* and routes
|
||||
across subnets, so this leg exercises real broadcast distribution cross-VM and is exactly the field
|
||||
topology. A genuinely broadcast-only leg (plain Who-Is with no BBMD) can only run **on the docker host
|
||||
@@ -498,6 +510,26 @@ slightly above OpcUaClient, driven by the callback→async adapter + COV lifetim
|
||||
Each phase is independently shippable (mirror how OpcUaClient landed across PRs):
|
||||
|
||||
- **P1 — Connect + Read + Discover (+ universal browse) + BBMD. (MVP, delivers real value.)**
|
||||
|
||||
**P1 opens with a first-spike verification checklist.** The library and fixture facts below came from
|
||||
research (`docs/research/drivers/bacnet-ip.md`) and were **unverifiable offline** — verify them on the
|
||||
wire before building anything on top of them:
|
||||
|
||||
1. **NuGet package reality.** Confirm the exact current version of the `BACnet` package (ela-compil
|
||||
fork) and that its TFM set actually restores + loads on **net10.0** (pin the confirmed version in
|
||||
`Directory.Packages.props` — see the §2 placeholder).
|
||||
2. **API-surface smoke test.** Exercise the specific API surface this design depends on:
|
||||
BBMD/foreign-device registration (`Register-Foreign-Device` + TTL), **segmented**
|
||||
ReadPropertyMultiple (a large `object-list` fetch), and SubscribeCOV callback delivery
|
||||
(`OnCOVNotification`). Any gap here reshapes §3/§4 before real work starts.
|
||||
3. **Fixture unicast I-Am behavior.** Verify the pinned `bacserv` and `BasicServer` fixture builds
|
||||
answer a **unicast** Who-Is with a **unicast** I-Am to the request's source address — a spec-strict
|
||||
stack may broadcast the I-Am, which would **silently break the hermetic cross-VM test path** §9
|
||||
relies on (the directed-Who-Is leg from the dev machine). If either fixture broadcasts, adjust the
|
||||
cross-VM suite to the **BBMD/foreign-device leg only** and keep directed-unicast tests on the
|
||||
docker host itself.
|
||||
|
||||
Then the P1 build proper:
|
||||
`.Contracts` (`BacnetDriverOptions`, enums, `BacnetTagAddress`, `BacnetEquipmentTagParser`);
|
||||
`BacnetClientAdapter` (async wrap); `BacnetDriver : IDriver, ITagDiscovery, IReadable,
|
||||
IHostConnectivityProbe` with `SupportsOnlineDiscovery=true` + `RediscoverPolicy =>
|
||||
@@ -518,7 +550,9 @@ Each phase is independently shippable (mirror how OpcUaClient landed across PRs)
|
||||
|
||||
1. **BBMD / foreign-device registration + UDP broadcast reachability (HIGH — the top risk).** The server
|
||||
is usually not on the controllers' L2 segment, so correct foreign-device registration + TTL renewal is
|
||||
load-bearing and is exactly what's hardest to reproduce in docker/CI. Mitigation: build BBMD in from
|
||||
load-bearing and is exactly what's hardest to reproduce in docker/CI. Note the AdminUI browse capture
|
||||
adds a **second** foreign-device registration off-segment (§4.2 deployment note) — account for it in
|
||||
BBMD FD-table sizing. Mitigation: build BBMD in from
|
||||
P1, test explicitly with the `bacnet-docker` multi-network compose, gate a live suite against a real
|
||||
site BBMD.
|
||||
2. **COV lifetime/renewal + non-COV fallback (HIGH, P2).** Subscriptions silently expire if not renewed;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# `Calculation` pseudo-driver — mini-design (v3 task #11)
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** design complete (resolves v3 open item "Calculation driver mini-design").
|
||||
**Parent:** `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` §"Calculated tags (two-tier)".
|
||||
|
||||
Signal-level calculated tags are ordinary **raw tags** bound to a `Calculation` driver. They
|
||||
inherit the entire raw pipeline (folders, tag-groups, CSV, historization, native alarms, UNS
|
||||
references) with no special-casing. This doc pins the runtime mechanism, storage, triggers,
|
||||
gates, and error semantics.
|
||||
|
||||
## 1. Driver shape
|
||||
|
||||
`CalculationDriver : IDriver, ISubscribable, IReadable` in a new
|
||||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` project.
|
||||
|
||||
- **Not `IWritable`** — calc tags are computed sources; author them read-only
|
||||
(`AccessLevel` enforced at authoring + CSV validation).
|
||||
- **Not `ITagDiscovery`** — tags are authored, never discovered
|
||||
(`SupportsOnlineDiscovery` default false ⇒ no Browse button, manual/CSV entry only —
|
||||
consistent with the universal-browser gate).
|
||||
- `IReadable.ReadAsync` returns the last computed snapshot per ref
|
||||
(`BadWaitingForInitialData` before first evaluation).
|
||||
- Registration: `CalculationDriverFactoryExtensions.DriverTypeName = "Calculation"` + one
|
||||
line in `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` and one `TryAddEnumerable` in
|
||||
`AddOtOpcUaDriverProbes` — the standard pattern. *(Note: the live registration seam is
|
||||
`DriverFactoryRegistry`; `DriverTypeRegistry` (metadata/JSON-schema) is vestigial — nothing
|
||||
in `src/` calls its `Register`.)*
|
||||
- Devices: one auto-created default device (`Engine`), empty `DeviceConfig`. No PollGroups.
|
||||
- `GetHealth()` = always Connected (no backend); memory footprint = compiled-cache size.
|
||||
|
||||
## 2. Per-tag `TagConfig`
|
||||
|
||||
```json
|
||||
{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }
|
||||
```
|
||||
|
||||
- `scriptId` (required) — logical FK to the **shared `Script` entity** (same convention as
|
||||
`VirtualTag.ScriptId`; generation-scoped, `SourceHash` compile-cache key). The AdminUI tag
|
||||
editor reuses the VirtualTagModal's script dropdown + "New script" + inline Monaco panel
|
||||
components; the standalone `/scripts/{id}` page works unchanged.
|
||||
- Trigger: `changeTriggered` (default **true**) and/or `timerIntervalMs` (≥ 50), at least one
|
||||
required — the authored shape mirrors `VirtualTag`, but see §4 for the runtime reality.
|
||||
|
||||
## 3. Value production — how inputs reach the driver
|
||||
|
||||
**The gap:** drivers are host-blind; nothing feeds one driver another driver's values. The
|
||||
per-node `DependencyMuxActor` already receives **every** `AttributeValuePublished` (via
|
||||
`DriverHostActor.ForwardToMux`), keyed by wire-ref — under v3, RawPath.
|
||||
|
||||
**Mechanism — a new composable capability interface** (in the spirit of `IDriver`'s
|
||||
capability set):
|
||||
|
||||
```csharp
|
||||
/// <summary>Optional capability: the driver consumes other tags' live values.
|
||||
/// The host registers the declared refs with its dependency mux and forwards changes.</summary>
|
||||
public interface IDependencyConsumer
|
||||
{
|
||||
/// <summary>Wire-refs (RawPaths) this driver needs, derived from its authored tags.
|
||||
/// Re-read by the host after Initialize/Reinitialize.</summary>
|
||||
IReadOnlyCollection<string> DependencyRefs { get; }
|
||||
|
||||
/// <summary>Host push of a dependency value change. Called from an actor context —
|
||||
/// implementations must be non-blocking.</summary>
|
||||
void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc);
|
||||
}
|
||||
```
|
||||
|
||||
`DriverHostActor` wiring (~30 lines): when a spawned driver implements `IDependencyConsumer`,
|
||||
spawn a tiny mux-adapter child that `RegisterInterest(driver.DependencyRefs, Self)` on the
|
||||
existing `DependencyMuxActor` and forwards `DependencyValueChanged` into the driver callback.
|
||||
Re-register on every apply (refs change when tags/scripts change).
|
||||
|
||||
**Output path is the ordinary driver path** — evaluation raises `ISubscribable.OnDataChange`
|
||||
→ `DriverInstanceActor` → `AttributeValuePublished` → node fan-out **and** back into the mux.
|
||||
Two things fall out for free:
|
||||
|
||||
1. **Calc-of-calc chains work with no extra machinery** — calc A's published output re-enters
|
||||
the mux, calc B (subscribed to A's RawPath) re-evaluates. This is exactly why the
|
||||
deploy-time **cycle gate** (§5) is mandatory: an undetected A→B→A cycle is a live
|
||||
oscillation loop, not just a stale value.
|
||||
2. Historization, native alarms, and UNS references attach to the calc tag's raw node exactly
|
||||
like any driver tag.
|
||||
|
||||
`DependencyRefs` = the union of `EquipmentScriptPaths.ExtractDependencyRefs(source)` over the
|
||||
driver's authored tags' scripts (literal-only `ctx.GetTag` paths, enforced by
|
||||
`DependencyExtractor`).
|
||||
|
||||
## 4. Evaluation engine + triggers
|
||||
|
||||
- **Evaluator:** a `CalculationEvaluator` with the same construction as
|
||||
`RoslynVirtualTagEvaluator` — `CompiledScriptCache<VirtualTagContext, object?>` keyed by
|
||||
source, `TimedScriptEvaluator` (2 s default timeout), passthrough fast-path, single-tag
|
||||
mode (`ctx.SetVirtualTag` dropped + logged). Reuses `VirtualTagContext` so the Monaco
|
||||
editor, sandbox, and diagnostics pipeline apply verbatim. Catalog keys for completions are
|
||||
RawPaths; the `{{equip}}` branch is already conditional in `ScriptAnalysisService` and
|
||||
simply never triggers for raw-level scripts.
|
||||
- **Change trigger:** evaluate on `OnDependencyValue` for any declared dep of that tag,
|
||||
gated like `VirtualTagActor`: publish nothing until all declared deps have arrived at least
|
||||
once; dedupe equal results.
|
||||
- **Timer trigger:** owned **inside the driver** by reusing the dormant-but-tested
|
||||
`TimerTriggerScheduler` (`Core.VirtualTags`) — one timer per distinct interval group,
|
||||
per-group in-flight skip. *(Fact: the live VirtualTag runtime is change-trigger only —
|
||||
`VirtualTagActor` has no timer handler and `EquipmentVirtualTagPlan` drops the trigger
|
||||
fields. The Calc driver revives the scheduler without touching the VT runtime; whether the
|
||||
VT plan seam later gains timer support is out of scope here.)*
|
||||
- **Concurrency:** evaluations run inline on the driver's dispatch (same "fast enough to run
|
||||
inline" contract as the VT evaluator); the compile cache makes steady-state evaluation
|
||||
cheap. Compiled scripts are dropped on `ReinitializeAsync` when the source set changed
|
||||
(`SourceHash` comparison, mirroring `IScriptCacheOwner` usage).
|
||||
|
||||
## 5. Deploy gates (new — `DraftValidator`)
|
||||
|
||||
Today `DraftValidator` checks only name uniqueness; `DependencyGraph` (Tarjan SCC) exists but
|
||||
is dormant. v3 adds, for `Calculation`-bound tags:
|
||||
|
||||
1. **`scriptId` existence** — the referenced `Script` row must exist in the draft generation.
|
||||
2. **Cycle gate (hard error)** — build the calc→calc edge set (tag → deps that are themselves
|
||||
Calculation tags) and run `DependencyGraph.DetectCycles`; any SCC ⇒ deploy error naming
|
||||
the cycle members. Cross-driver refs (calc reading a Modbus tag) are terminal nodes,
|
||||
never edges.
|
||||
3. **Compile: deliberately NOT a hard gate** — parity with VirtualTags. The editor's live
|
||||
diagnostics mirror the compile exactly, the `StartDeployment` compile-cost advisory stays
|
||||
warn-only, and a runtime compile failure lands as Bad quality + script-log (§6). A hard
|
||||
compile gate would make deploys O(scripts) slow and diverge Calc tags from VT behavior.
|
||||
|
||||
## 6. Error semantics (mirrors `VirtualTagActor` 02/S13)
|
||||
|
||||
- Evaluator throw / compile error / timeout ⇒ publish **Bad** quality carrying the last-known
|
||||
value, once per Good→Bad transition, only after all deps have arrived; emit a
|
||||
`ScriptLogEntry` on the `script-logs` DPS topic per failure (visible on the script-log
|
||||
panel). Recovery to Good is force-published. No retry — re-evaluation on next trigger.
|
||||
- Historized Bad results record `BadInternalError` (0x80020000), matching VT historization.
|
||||
|
||||
## 7. Probe
|
||||
|
||||
`CalculationDriverProbe : IDriverProbe` (`DriverType = "Calculation"`). `ProbeAsync` =
|
||||
validate the driver config parses; there is no backend to connect. (Per-script compile
|
||||
verification belongs to the editor diagnostics + the tag editor's inline panel, not the
|
||||
driver probe — the probe receives only `DriverConfig`, which for Calculation is empty.)
|
||||
Returns Ok with negligible latency; never throws.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **Unit:** evaluator parity tests vs the VT evaluator (passthrough, timeout, sandbox
|
||||
violation, SetVirtualTag drop); dependency-ref extraction from authored tags; change-gate
|
||||
(no publish until all deps seen); dedupe; timer-group scheduling via
|
||||
`TimerTriggerScheduler`; Bad-transition + script-log emission; cycle gate (self-cycle,
|
||||
2-cycle, cross-driver terminal).
|
||||
- **Integration:** deploy a Calc tag reading a fixture-driver tag on the 2-node harness;
|
||||
assert the raw node publishes computed values, a UNS reference sees them, and calc-of-calc
|
||||
chains propagate.
|
||||
- **Live `/run` (Batch 2 gate):** author a Calc driver + tag on docker-dev via `/raw`,
|
||||
script it against a Modbus fixture tag, watch the value compute; break the script, watch
|
||||
Bad + script-log row; fix, watch recovery.
|
||||
|
||||
## 9. Out of scope
|
||||
|
||||
- Multi-tag scripts / `ctx.SetVirtualTag` fan-out (single-tag mode, as VT today).
|
||||
- Timer support in the UNS VirtualTag runtime (dormant there; revived only inside this driver).
|
||||
- Write-through calc tags (inverse calculations).
|
||||
@@ -100,6 +100,10 @@ driver-typed-editors design):
|
||||
fleet plumbing lands.
|
||||
- **Secrets** — connection strings / API keys / broker creds come from env/secret refs (mirror
|
||||
`ServerHistorian__ApiKey`), never committed or logged.
|
||||
- **Ctor is connection-free** — driver constructors must not open connections or touch the
|
||||
network; all connects happen in `InitializeAsync`. Every existing driver already obeys this,
|
||||
and the universal browser's `CanBrowse` throwaway-instance pattern **depends** on it — a
|
||||
ctor-connecting driver would leak connections on every capability probe.
|
||||
- **Live-verify discipline** — Razor binding + deploy-inertness bugs pass unit tests and review;
|
||||
every driver's picker/editor and deploy path gets a docker-dev `/run` live-verify before it's
|
||||
trusted.
|
||||
|
||||
@@ -296,7 +296,11 @@ is an address *builder* (grammar helper), not a live browser, and stays valid fo
|
||||
checked-in compose services carry no `labels:` entries today). Drive it via
|
||||
`lmxopcua-fix up modbus rtu_over_tcp` + `lmxopcua-fix sync modbus`. This exercises the
|
||||
real CRC + FC-aware framing end-to-end with **no serial anything** — the only
|
||||
genuinely new logic.
|
||||
genuinely new logic. **First P1 step: confirm the pymodbus simulator entrypoint this
|
||||
fixture uses actually exposes the RTU framer on a TCP server** (pymodbus 3.x supports
|
||||
`framer=rtu` on TCP servers generally, but the simulator CLI/json config may not
|
||||
surface the knob); if it doesn't, fall back to a small custom server script alongside
|
||||
the existing `exception_injector.py` pattern.
|
||||
2. **`diagslave`** (TCP RTU mode) for manual/soak — optional, not unit CI.
|
||||
3. ~~Virtual serial pair (socat pty ↔ pymodbus RTU serial slave; com0com on Windows)~~ —
|
||||
**descoped with the direct-serial transport**; would return with it.
|
||||
|
||||
@@ -73,7 +73,7 @@ tests/Drivers/.../Docker/docker-compose.yml # mosquitto + sparkplug-sim +
|
||||
|
||||
| Concern | Choice | Package |
|
||||
|---|---|---|
|
||||
| **MQTT transport (both modes)** | **MQTTnet v5** — MIT, **.NET Foundation** (`dotnet/MQTTnet`), the standard .NET MQTT client. v5 explicitly ships a `net10.0` TFM. Client + TLS + MQTT 3.1.1/5.0. | `MQTTnet` (add `PackageVersion Include="MQTTnet" Version="5.x"` to `Directory.Packages.props`) |
|
||||
| **MQTT transport (both modes)** | **MQTTnet v5** — MIT, **.NET Foundation** (`dotnet/MQTTnet`), the standard .NET MQTT client. v5 explicitly ships a `net10.0` TFM. Client + TLS + MQTT 3.1.1/5.0. **Note:** v5 dropped v4's `ManagedMqttClient` — reconnect is hand-rolled in P1 (§8). | `MQTTnet` (add `PackageVersion Include="MQTTnet" Version="5.x"` to `Directory.Packages.props`) |
|
||||
| **Sparkplug decode** | **Hand-rolled Tahu protobuf** (recommended) — generate C# from Tahu's `sparkplug_b.proto` with `Google.Protobuf` (`Grpc.Tools`/`protoc`), decode over a single MQTTnet-5 client. Small, stable schema; we own the alias/seq/rebirth state machine exactly as we want it. | `Google.Protobuf` + `Grpc.Tools` (build-time) |
|
||||
| **Alternative Sparkplug decode** | **SparkplugNet** — MIT, provides `Application`/`Node`/`Device` base classes, saves the birth/alias plumbing. **Rejected as primary** for two repo-specific reasons (below). | `SparkplugNet` (only if the risk clears) |
|
||||
|
||||
@@ -81,6 +81,8 @@ tests/Drivers/.../Docker/docker-compose.yml # mosquitto + sparkplug-sim +
|
||||
|
||||
**Decision: hand-roll Tahu-proto decode over one MQTTnet-5 client.** It removes the diamond and the net10-TFM concern entirely, and the Sparkplug payload schema (`Payload { seq, timestamp, Metric[], body }`, `Metric { name, alias, timestamp, datatype, value, is_null, ... }`) is ~90 lines of `.proto` — a bounded, well-understood effort. Phase 1 (plain MQTT) references **only MQTTnet-5**, so we validate MQTTnet-v5-on-net10 before ever touching Sparkplug. (If a future need for SparkplugNet arises, the phase-2 codec seam `SparkplugCodec` is the single swap point.)
|
||||
|
||||
- [ ] **Re-verify the external-library claims at Wave-2 start (before P1 coding):** confirm the exact **MQTTnet v5 version + `net10.0` TFM** on NuGet, and re-check the **SparkplugNet MQTTnet-4.x transitive-pin** claim — it is directionally right from the research but was unverifiable offline. The hand-roll decision does **not** hinge on that pin being exact (it stands on the net10-TFM concern + owning the alias/seq/rebirth state machine regardless), so this is a confirm-the-record check, not a decision gate.
|
||||
|
||||
**Vendor the proto:** commit Tahu `sparkplug_b.proto` into `Driver.Mqtt.Contracts/Protos/` (pinned to a known Tahu commit, with a provenance comment) and generate at build via `Grpc.Tools`. Don't fetch at build time.
|
||||
|
||||
**Existing protobuf tooling, honestly:** `Google.Protobuf` is **already centrally pinned** (`Directory.Packages.props:31`, 3.34.1) and consumed by `Driver.Galaxy` — but the Galaxy proto types ship **pre-generated** inside the `ZB.MOM.WW.MxGateway.*` NuGets; there is **no `.proto` file or `Grpc.Tools` protoc codegen anywhere in this repo today**. So `Grpc.Tools` is a new (build-time-only, `PrivateAssets=all`) package and this is the repo's first in-repo proto codegen step. If that step proves objectionable, the fallback is checking in the generated C# (the schema is small and stable) with the same provenance comment.
|
||||
@@ -103,6 +105,8 @@ tests/Drivers/.../Docker/docker-compose.yml # mosquitto + sparkplug-sim +
|
||||
|
||||
- **Plain mode:** subscribe to each authored tag's `topic` at its `qos` (dedupe identical topics; coalesce shared wildcards where the operator authored one). On message: match topic → authored tag(s), extract value at `jsonPath` (or raw/scalar), raise `OnDataChange(handle, FullReference, snapshot)`. Seed initial value from the broker's **retained** message replayed on subscribe (OPC UA initial-data convention).
|
||||
- **Sparkplug mode:** subscribe `spBv1.0/{groupId}/#` (+ the host-state topic when acting as primary host — **`spBv1.0/STATE/{hostId}` with a JSON `{online, timestamp}` payload in spec v3.0**; pre-3.0 peers used top-level `STATE/{hostId}` with `ONLINE`/`OFFLINE` text — target the v3.0 form, tolerate the legacy one on receive). Maintain a **`BirthCache` + `AliasTable` per (edgeNode, device)** from NBIRTH/DBIRTH. On NDATA/DDATA: resolve each metric by **alias → (name, datatype)** via the alias table, map to the authored tag's `FullReference` **by stable metric name**, raise `OnDataChange`. On NDEATH/DDEATH: emit **STALE / Bad-quality** snapshots for that node's/device's metrics. On missed birth / unknown alias / seq gap: issue a **rebirth NCMD** (§3.6).
|
||||
**Spec version:** implement from the **Sparkplug v3.0 spec text** — the research report cites v2.2, which is superseded on exactly the STATE topic/payload point above (this doc already carries the correct v3.0 form).
|
||||
|
||||
- `publishingInterval` is **advisory** (MQTT/Sparkplug are event-driven). Use it only for optional server-side coalescing/deadband, mirroring Modbus's `ShouldPublish`. `SubscribeAsync` returns an `ISubscriptionHandle` whose `DiagnosticId` names the mode+filter; `OnDataChange` fires from the MQTT receive handler for the whole driver lifetime.
|
||||
|
||||
`SubscribeAsync` maps the requested `FullReference`s onto the live receive stream: it registers them in a `FullReference → MqttTagDefinition` resolver (parsed once, cached — reuse the shared `EquipmentTagRefResolver<TDef>` from `Core.Abstractions`, exactly as Modbus does), so an inbound message/metric routes to the right node.
|
||||
@@ -156,7 +160,7 @@ The alias→metric map, seq-gap detection, late-join rebirth, and death→STALE
|
||||
2. **Rebuild the alias table on every (re)birth.** NBIRTH/DBIRTH replaces the `(edgeNode,device)` `AliasTable` wholesale; never merge.
|
||||
3. **Track `seq` (0–255, wraps) per edge-node.** A gap (or an unknown alias, or a data message before any birth) ⇒ request rebirth (`RebirthRequester` publishes `NCMD` writing `Node Control/Rebirth = true`), gated on `requestRebirthOnGap`. `bdSeq` ties an NDEATH to its NBIRTH.
|
||||
4. **Death → STALE.** NDEATH/DDEATH (LWT-driven) emits Bad/uncertain-quality snapshots for all affected metrics; the next birth restores Good.
|
||||
5. **Late join.** On connect the driver may have missed births — proactively request rebirth (or wait for the retained STATE + natural rebirth) so metadata is recovered. This is the same primitive the browser uses (§4).
|
||||
5. **Late join.** On connect the driver may have missed births — proactively request rebirth (or wait for the retained STATE + natural rebirth) so metadata is recovered. (This runtime-driver late-join rebirth is automatic and correct — the driver *owns* its subscription; it is the same NCMD primitive the browser exposes only as the explicit operator "Request rebirth" action, §4.1.)
|
||||
|
||||
Unit-test these explicitly against a controllable simulator: rebirth, missed-birth, seq-gap, alias-reuse-across-rebirth, death→stale→rebirth.
|
||||
|
||||
@@ -177,7 +181,7 @@ v1 ships **read/subscribe/discover only** — a genuinely useful UNS-ingest driv
|
||||
The universal browser (`docs/plans/2026-07-15-universal-discovery-browser-design.md`) captures whatever `DiscoverAsync` streams and re-presents it, gated on `SupportsOnlineDiscovery`. **MQTT/Sparkplug is the one roadmap driver that needs a bespoke browser despite the universal one**, because:
|
||||
|
||||
- Runtime `DiscoverAsync` is **authored-only** (§3.3). A universal Discover-backed browser would replay only tags the operator already authored — it would **not discover new topics/metrics**, which is the entire point of a picker.
|
||||
- MQTT/Sparkplug discovery is **observational**: you learn what exists only by *listening* for a bounded window. That's a fundamentally different session shape than "connect → one-shot enumerate → disconnect" — the tree **fills in over time**, and Sparkplug can be *accelerated* by forcing a rebirth NCMD.
|
||||
- MQTT/Sparkplug discovery is **observational**: you learn what exists only by *listening* for a bounded window. That's a fundamentally different session shape than "connect → one-shot enumerate → disconnect" — the tree **fills in over time**, and Sparkplug can be *accelerated on demand* by an operator-requested rebirth NCMD (§4.1 — explicitly not automatic).
|
||||
|
||||
**Considered + rejected:** feeding the Sparkplug `BirthCache` into `DiscoverAsync` device-enumeration to plug into universal. Rejected because it would make runtime discovery auto-provision from births (the unbounded-address-space failure §3.3 explicitly avoids) and still wouldn't cover plain MQTT. The birth cache stays a browse-time + subscribe-time structure, not a discovery source.
|
||||
|
||||
@@ -189,16 +193,19 @@ The universal browser (`docs/plans/2026-07-15-universal-discovery-browser-design
|
||||
1. Parse the **same `MqttDriverOptions` JSON** the runtime driver consumes (shared `JsonSerializerOptions`), but connect with a **distinct client-id suffix** (`{clientId}-browse-{guid8}`) so the browse session never collides with the running driver's MQTT session / clean-session state.
|
||||
2. Connect an MQTTnet client under a bounded connect budget (clamp like OpcUaClient's `PerEndpointConnectTimeout`, 5–30 s).
|
||||
3. Subscribe to the discovery filter: `spBv1.0/{groupId}/#` (Sparkplug) or `#`/`{topicPrefix}#` (plain).
|
||||
4. **Kick off the observation window immediately** and, in Sparkplug mode, optionally publish a **rebirth NCMD** to force every edge node to re-announce (turns the passive wait into a near-synchronous enumeration).
|
||||
4. **Kick off the observation window immediately — passively.** The session only *listens*; opening it publishes **nothing**. This keeps browse read-only by default, matching the house browse pattern (driver-browsers design §6: "browse is read-only and doesn't mutate config or driver state"), and avoids rebirth-storming every edge node in the group whenever an operator merely opens a picker — on a large plant an auto-rebirth on session open would stampede the whole group's edge nodes into re-publishing full births.
|
||||
5. Return an `MqttBrowseSession` whose internal tree **fills in over the window and keeps filling** while the session lives. Because `IBrowseSession` methods are async and the session lands in the AdminUI `BrowseSessionRegistry` (TTL-reaped, per-call 20 s timeout), the observation cost is amortised across the user's clicks in the picker.
|
||||
|
||||
**Explicit "Request rebirth" — the one operator-initiated write (Sparkplug mode only).** The picker carries a **"Request rebirth"** button, enabled in Sparkplug mode and **scoped to the currently selected group or edge node**. Clicking it invokes `MqttBrowseSession.RequestRebirthAsync(scope)` — an MQTT-specific session method *beyond* the base `IBrowseSession` surface, dispatched by the picker through `BrowserSessionService` for MQTT sessions — which publishes the rebirth **NCMD** (`Node Control/Rebirth = true`, via the same `RebirthRequester` encode path) to the selected edge node, or to each *observed* edge node under the selected group. That converts the passive wait into a near-synchronous enumeration precisely when the operator asks for it. This is the design's single deliberate deviation from browse-is-read-only: it is operator-initiated, scoped, gated by the same `DriverOperator` policy that gates the picker, and logged (Info, with the target scope) — it is **never** fired automatically by `OpenAsync`, `RootAsync`, or expansion.
|
||||
|
||||
### 4.2 `MqttBrowseSession : IBrowseSession`
|
||||
|
||||
Serves from an **accumulating observed tree** (thread-safe; the MQTT receive handler mutates it, browse calls read it). Refreshes `LastUsedUtc` on each call.
|
||||
|
||||
- **`RootAsync`** — Sparkplug: observed **groups** (or edge nodes under the configured group). Plain: top-level topic segments. `BrowseNode { Kind=Folder, HasChildrenHint=true }`. If the window hasn't yielded yet, return what's accumulated (possibly empty) and let the user re-expand; in Sparkplug mode trigger a rebirth on first `RootAsync` to populate fast.
|
||||
- **`RootAsync`** — Sparkplug: observed **groups** (or edge nodes under the configured group). Plain: top-level topic segments. `BrowseNode { Kind=Folder, HasChildrenHint=true }`. If the window hasn't yielded yet, return what's accumulated (possibly empty) and let the user re-expand — or, in Sparkplug mode, click the explicit **"Request rebirth"** button (§4.1) to populate fast. `RootAsync` itself never publishes; browse calls are pure reads of the accumulated tree.
|
||||
- **`ExpandAsync(nodeId)`** — one level down the accumulated tree. Sparkplug: **Group → EdgeNode → Device → Metric**. Plain: next topic segment (split on `/`). Metrics / leaf-topics are `Kind=Leaf`.
|
||||
- **`AttributesAsync(nodeId)`** — Sparkplug: the metric's `AttributeInfo { Name, DriverDataType (from birth), IsArray, SecurityClass }` — **self-describing**, so the picker side-panel is populated (unlike the OPC UA browser which returns empty). Plain: a single synthetic attribute carrying the leaf topic's **inferred type + last-seen payload snippet**.
|
||||
- **`RequestRebirthAsync(scope)`** — Sparkplug only, *beyond* the base `IBrowseSession` surface (§4.1): the single session member that publishes, and only ever on explicit operator click.
|
||||
- **`DisposeAsync`** — disconnect the MQTTnet client, best-effort (registry reaper may race), same pattern as `OpcUaClientBrowseSession`.
|
||||
|
||||
### 4.3 Picked node → TagConfig
|
||||
@@ -210,7 +217,7 @@ On commit the picker projects the selected `BrowseNode`/`AttributeInfo` into the
|
||||
### 4.4 Addressing the passive/time-bounded wrinkle explicitly
|
||||
|
||||
1. Picker shows a live **"listening… N nodes/topics discovered"** affordance so the operator understands coverage is accumulating, not instantaneous.
|
||||
2. Sparkplug mode **actively forces a rebirth** to convert the passive wait into a fast near-complete enumeration.
|
||||
2. Sparkplug mode offers the explicit **"Request rebirth"** button (§4.1) — operator-initiated, scoped to the selected group/node — to convert the passive wait into a fast near-complete enumeration on demand. The window itself stays passive; if the operator doesn't click, coverage accumulates from natural traffic only.
|
||||
3. The browse session stays alive (registry TTL) so re-expanding later reflects newly observed topics without reconnecting.
|
||||
4. **Manual entry** of a topic/metric via the typed editor (§6) is always available as an escape hatch for a tag that's silent during the window.
|
||||
|
||||
@@ -377,7 +384,7 @@ services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, Driver.Mqtt.
|
||||
## 8. Resilience / timeout / security
|
||||
|
||||
- **Bounded connect.** `InitializeAsync` connects under `connectTimeoutSeconds` (default 15) via a linked CTS — **no unbounded wait** on a dead broker (heed the S7/FOCAS read-timeout lessons: an async network call that ignores its deadline wedges the whole driver).
|
||||
- **Reconnect with backoff.** MQTTnet's managed reconnect (or a hand-rolled loop) with exponential backoff `reconnectMinBackoffSeconds → reconnectMaxBackoffSeconds`. On disconnect: `GetHealth` → Reconnecting; on reconnect: re-subscribe, and in Sparkplug mode **request rebirth** to recover metadata (late-join, §3.6). Nodes go Bad-quality while disconnected via the standard fan-out.
|
||||
- **Reconnect with backoff — hand-rolled, committed for P1.** MQTTnet v5 **dropped** the v4 `ManagedMqttClient`, so there is no library-managed reconnect to lean on: `MqttConnection` owns a hand-rolled reconnect loop. Requirements: exponential backoff `reconnectMinBackoffSeconds → reconnectMaxBackoffSeconds`; **re-subscribe on every reconnect** (never assume broker-held subscription state — with `cleanSession=true` subscriptions don't survive the session, and even with a persistent session treat re-subscribe as idempotent insurance); and session-state handling (honour `cleanSession`/session-expiry semantics; re-establish the Sparkplug STATE birth when acting as primary host). On disconnect: `GetHealth` → Reconnecting; on reconnect: re-subscribe, and in Sparkplug mode **request rebirth** to recover metadata (late-join, §3.6). Nodes go Bad-quality while disconnected via the standard fan-out.
|
||||
- **Subscribe deadline.** `SubscribeAsync` completes under a bounded deadline; SUBACK failure surfaces as a per-reference Bad, not a hang.
|
||||
- **No poll loop** — the receive handler must be non-blocking (offload heavy decode off the MQTT callback thread; never block the MQTTnet dispatcher).
|
||||
- **Broker security — enforce, never ship public-broker defaults.** Default `useTls=true`; real auth (username/password or client-cert). `allowUntrustedServerCertificate` + `caCertificatePath` mirror the ServerHistorian TLS knobs (dev/on-prem escape hatch, off by default). **Never** put Sparkplug production-shaped data on public brokers (`test.mosquitto.org` etc. — manual smoke only). Credentials come from env (`password` blank in committed JSON), same posture as `ServerHistorian__ApiKey`.
|
||||
@@ -392,7 +399,7 @@ Follow the driver-fixture pattern: `tests/Drivers/.../Docker/docker-compose.yml`
|
||||
- **Broker:** **Eclipse Mosquitto** (`eclipse-mosquitto`, tiny, carries both plain + Sparkplug unchanged) as the default fixture; **EMQX** (`emqx/emqx`, Apache-2.0 CE) as the alternative when a dashboard / built-in Sparkplug tooling helps. Configure with **auth + TLS** so the fixtures exercise the real security path (not anonymous).
|
||||
- **Sparkplug edge-node simulator:** prefer a **project-owned C# simulator** using the same MQTTnet + Tahu-proto path the driver uses — validates encode/decode symmetrically and gives full control of rebirth/seq-gap/death scenarios (the §3.6 test matrix). Alternatives: Eclipse Tahu reference (Java/Python) edge-node script; Bevywise MQTT/IoT Simulator (purpose-built Sparkplug B). The simulator must publish NBIRTH/DBIRTH → periodic NDATA/DDATA, honour a rebirth NCMD, and be able to inject a seq gap / alias reuse / N-D-DEATH on demand.
|
||||
- **Plain-MQTT fixture:** a tiny publisher container (`mosquitto_pub` loop or a `paho-mqtt` script) emitting JSON on a handful of topics with `retain=true` so the last-value/read path **and** the `#`-observation browse are testable.
|
||||
- **Env-gated live suite:** `Driver.Mqtt.IntegrationTests`, category `LiveIntegration` (à la HistorianGateway), skips cleanly when **`MQTT_FIXTURE_ENDPOINT`** is unset → macOS-offline-safe. Covers: connect/TLS/auth; plain subscribe+retained-read; Sparkplug birth→data→alias-resolve→`OnDataChange`; rebirth recovery; death→STALE; the browser observation window (birth-driven tree + rebirth-forced enumeration).
|
||||
- **Env-gated live suite:** `Driver.Mqtt.IntegrationTests`, category `LiveIntegration` (à la HistorianGateway), skips cleanly when **`MQTT_FIXTURE_ENDPOINT`** is unset → macOS-offline-safe. Covers: connect/TLS/auth; plain subscribe+retained-read; Sparkplug birth→data→alias-resolve→`OnDataChange`; rebirth recovery; death→STALE; the browser observation window (passive birth-driven tree accumulation — assert no NCMD is published by open/browse calls — plus the explicit `RequestRebirthAsync` enumeration, scoped to node vs. group).
|
||||
- **Live-verify (docker-dev):** author an Mqtt driver + tag via the `/uns` picker on `:9200`, deploy, confirm the node carries live broker values and the typed editor renders both modes (the usual live-`/run` discipline — Razor binding bugs pass unit tests).
|
||||
|
||||
Add the fixture endpoint to `CLAUDE.md`'s endpoint list once landed (e.g. `10.100.0.35:1883`/`8883`, `MQTT_FIXTURE_ENDPOINT`).
|
||||
@@ -405,8 +412,8 @@ Order: **plain MQTT first, then Sparkplug B, then write.** Plain MQTT is the dep
|
||||
|
||||
| Phase | Scope | Rel. effort |
|
||||
|---|---|---|
|
||||
| **P1 — Plain MQTT** | 3 projects; MQTTnet-5 connect/TLS/auth/reconnect+backoff (`MqttConnection`); `ISubscribable` (topic subscribe → `OnDataChange`); `IReadable` last-value (retained seed); authored-tag `ITagDiscovery` (`Once`); `IHostConnectivityProbe`; `MqttDriverProbe`; factory + host wiring; `#`-observation browser (`MqttDriverBrowser`/`MqttBrowseSession`); typed editor + validator + AdminUI driver page; Mosquitto + JSON-publisher fixtures + env-gated suite. **Validate MQTTnet-5/net10 here.** | **M** |
|
||||
| **P2 — Sparkplug B ingest** | Vendored Tahu proto + `Google.Protobuf` codegen; `SparkplugCodec` decode; `spBv1.0/{group}/#` subscribe; N/DBIRTH → `BirthCache`/`AliasTable`; bind-by-name + seq-gap detect + **rebirth NCMD**; N/DDEATH → STALE; STATE/primary-host option; `ITagDiscovery` `UntilStable` + `IRediscoverable`; birth-driven browser tree + `AttributesAsync`; Sparkplug datatype map; C# edge-node simulator fixture + §3.6 test matrix. | **L** |
|
||||
| **P1 — Plain MQTT** | 3 projects; MQTTnet-5 connect/TLS/auth + the **hand-rolled reconnect loop** (backoff + resubscribe-on-reconnect + session-state handling, §8 — v5 has no `ManagedMqttClient`) in `MqttConnection`; `ISubscribable` (topic subscribe → `OnDataChange`); `IReadable` last-value (retained seed); authored-tag `ITagDiscovery` (`Once`); `IHostConnectivityProbe`; `MqttDriverProbe`; factory + host wiring; `#`-observation browser (`MqttDriverBrowser`/`MqttBrowseSession`); typed editor + validator + AdminUI driver page; Mosquitto + JSON-publisher fixtures + env-gated suite. **Validate MQTTnet-5/net10 here.** | **M** |
|
||||
| **P2 — Sparkplug B ingest** | Vendored Tahu proto + `Google.Protobuf` codegen; `SparkplugCodec` decode; `spBv1.0/{group}/#` subscribe; N/DBIRTH → `BirthCache`/`AliasTable`; bind-by-name + seq-gap detect + **rebirth NCMD**; N/DDEATH → STALE; STATE/primary-host option; `ITagDiscovery` `UntilStable` + `IRediscoverable`; passive birth-driven browser tree + `AttributesAsync` + the operator **"Request rebirth"** picker action (§4.1); Sparkplug datatype map; C# edge-node simulator fixture + §3.6 test matrix. | **L** |
|
||||
| **P3 — Write-through (optional)** | `IWritable`: Sparkplug NCMD/DCMD + plain publish; optimistic-Good + self-correct on echo; `WriteIdempotent` respect (default off for non-idempotent Sparkplug commands); write-topic config. | **M** |
|
||||
|
||||
**Top risks:** (1) **Sparkplug state-machine correctness** (aliases + rebirth + seq — §3.6; bind-by-name is the mitigation, test against a controllable simulator); (2) **library/dependency friction** — SparkplugNet's MQTTnet-4.x transitive pin ✕ this repo's fragile central pinning (transitive pinning off), mitigated by **hand-rolling the Tahu proto over one MQTTnet-5 client** (§2.1). Secondary: passive-browse coverage gaps (rebirth-forcing + manual entry), unbounded address space (authored-only runtime discovery), write-has-no-ack (accepted, optimistic-Good like Galaxy), broker security (TLS + real auth enforced).
|
||||
**Top risks:** (1) **Sparkplug state-machine correctness** (aliases + rebirth + seq — §3.6; bind-by-name is the mitigation, test against a controllable simulator); (2) **library/dependency friction** — SparkplugNet's MQTTnet-4.x transitive pin ✕ this repo's fragile central pinning (transitive pinning off), mitigated by **hand-rolling the Tahu proto over one MQTTnet-5 client** (§2.1). Secondary: passive-browse coverage gaps (the explicit "Request rebirth" action + manual entry — the window itself never publishes), unbounded address space (authored-only runtime discovery), write-has-no-ack (accepted, optimistic-Good like Galaxy), broker security (TLS + real auth enforced).
|
||||
|
||||
@@ -49,7 +49,7 @@ Consume **TrakHound MTConnect.NET client packages (MIT)** — pin an exact versi
|
||||
|
||||
These target `netstandard2.0` (load cleanly on .NET 10) and give `MTConnectHttpClient` (probe/current/sample with polling **and** the multipart long-poll stream, gzip, XML+JSON) plus the model types (`IDevice`/`IComponent`/`IDataItem`/`IObservation`) — removing the multipart-framing / version-negotiation / buffer-overflow-rebaseline grind.
|
||||
|
||||
> **License-pin caveat (from research §1.4):** older TrakHound releases carried mixed MIT / Apache-2.0 / "all rights reserved" strings. Before merge, confirm the embedded `LICENSE` of the *pinned* version is MIT (a legal-review checkbox, not a blocker). **Hand-roll fallback** if review fails: `HttpClient` + `System.Xml.Linq` for probe/current, a `multipart/x-mixed-replace` boundary reader for sample (~300–500 LoC behind the same `IMTConnectAgentClient` seam — the driver above the seam is unchanged, so the fallback is a drop-in of one class).
|
||||
> **Library verification checklist (from research §1.4 — research-sourced facts, unverifiable offline).** Older TrakHound releases carried mixed MIT / Apache-2.0 / "all rights reserved" strings, and the version/TFM facts above came from research, not from a restore. **At implementation start, verify against the actual NuGet packages:** (1) the exact package version pin — confirm `6.9.0.2` (or the then-current pin) exists on nuget.org and restores; (2) its TFM set — confirm the packages ship `netstandard2.0` (or a net-10-compatible) target and load cleanly on .NET 10; (3) the license of the *pinned* version — confirm the embedded `LICENSE` is MIT (the before-merge legal-review checkbox, not a blocker). **Hand-roll fallback** if review fails: `HttpClient` + `System.Xml.Linq` for probe/current, a `multipart/x-mixed-replace` boundary reader for sample (~300–500 LoC behind the same `IMTConnectAgentClient` seam — the driver above the seam is unchanged, so the fallback is a drop-in of one class).
|
||||
|
||||
## 3. Capability mapping (concrete seam wiring)
|
||||
|
||||
@@ -76,7 +76,8 @@ public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken
|
||||
- each `Device` → `builder.Folder(device.Name, device.Name)` → child builder;
|
||||
- each nested `Component` → recursive `Folder(component.Name, component.DisplayName)` on the parent's child builder (component nesting becomes folder nesting — the builder-graph *is* the tree);
|
||||
- each `DataItem` → `child.Variable(browseName, displayName, attr)` where
|
||||
`attr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: MTConnectDataTypeInference.Infer(category, type, units), IsArray: representation==TIME_SERIES, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category==CONDITION)`.
|
||||
`attr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: MTConnectDataTypeInference.Infer(category, type, units), IsArray: representation==TIME_SERIES, ArrayDim: representation==TIME_SERIES && dataItem.SampleCount is int n and > 0 ? n : null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category==CONDITION)`.
|
||||
(For `TIME_SERIES` items the probe-declared `sampleCount` attribute flows into `ArrayDim` — the record's doc-comment defines `ArrayDim` as the declared array length when `IsArray` is true. `null` only when the device model doesn't declare `sampleCount`; many agents omit it for variable-length series.)
|
||||
(`ViewOnly` is the read-only-from-OPC-UA tier — `SecurityClassification` has no `ReadOnly` member.)
|
||||
`browseName` = `dataItem.Name ?? dataItem.Id` (dataItem `name` is optional in MTConnect; fall back to `id`, which is guaranteed unique).
|
||||
|
||||
@@ -91,13 +92,13 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
|
||||
| MTConnect | `DriverDataType` |
|
||||
|---|---|
|
||||
| `SAMPLE` numeric (has `units`) | `Float64` |
|
||||
| `SAMPLE` `representation=TIME_SERIES` | `Float64` array (`IsArray=true`) |
|
||||
| `SAMPLE` `representation=TIME_SERIES` | `Float64` array (`IsArray=true`; `ArrayDim` = probe-declared `sampleCount` when present, else null — §3.2) |
|
||||
| `EVENT` numeric type (`PartCount`, `Line`, …) | `Int64` |
|
||||
| `EVENT` controlled-vocab (`Execution`, `ControllerMode`, `Availability`, …) | `String` |
|
||||
| `EVENT` free text (`Program`, `Block`, `Message`) | `String` |
|
||||
| `CONDITION` | `String` (state word; §3.6) |
|
||||
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → OPC UA **`Bad`/`Uncertain`**, not the literal string. Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
**Quality mapping (`DataValueSnapshot.StatusCode`):** an observation value of `UNAVAILABLE` (MTConnect's explicit no-data sentinel) → **`BadNoCommunication` (`0x80310000u`)** with a null value, not the literal string — this is *the* UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, `UNAVAILABLE` means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's `BadNoCommunication` ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's `BadCommunicationError` (`0x80050000u`, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the **driver's own transport failure** to its peer — a different failure (and the code this driver also uses when the *Agent* HTTP call fails, per §7) — while `BadNoData` (`0x800E0000u`) is historian-domain only (`Historian.Gateway/Mapping/SampleMapper.cs`). `BadNoCommunication` is already in the CLI's `SnapshotFormatter` name table (`src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs`), so it renders by name, not hex. Declare it as a `private const uint` in `MTConnectDriver` (the Modbus `StatusBadCommunicationError` pattern). Missing dataItem / empty condition → `Bad`. `observation.timestamp` → `SourceTimestamp` (not a separate node).
|
||||
|
||||
### 3.4 `IReadable` — `/current`
|
||||
|
||||
@@ -161,14 +162,14 @@ Avoid the raw-JSON fallback by adding a typed editor (mirrors the Modbus templat
|
||||
|
||||
Every agent HTTP request MUST carry a **per-call deadline** — never an unbounded wait (the R2-01 S7 finding: an async read that ignores its socket timeout wedges the poll loop against a frozen peer). Concretely:
|
||||
|
||||
- **`/probe` and `/current`:** wrap each call in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct`; a timeout surfaces as a `Bad`-coded snapshot (read) or a Faulted init (probe), never a hang. Set `HttpClient.Timeout` **and** a linked CTS (belt-and-suspenders — `HttpClient.Timeout` doesn't cover the response-body read of a streamed multipart).
|
||||
- **`/probe` and `/current`:** wrap each call in a `CancellationTokenSource(RequestTimeoutMs)` linked to the caller's `ct`; a timeout surfaces as a `BadCommunicationError`-coded snapshot (read — the fleet-standard driver-transport-failure code, distinct from the §3.3 `BadNoCommunication` UNAVAILABLE mapping) or a Faulted init (probe), never a hang. Set `HttpClient.Timeout` **and** a linked CTS (belt-and-suspenders — `HttpClient.Timeout` doesn't cover the response-body read of a streamed multipart).
|
||||
- **`/sample` long-poll stream watchdog:** the stream is intentionally long-lived, so `HttpClient.Timeout` cannot bound it. Instead run a **watchdog**: if no chunk **and** no keep-alive heartbeat arrives within `HeartbeatMs × N` (e.g. 3× the Agent heartbeat), treat the stream as dead → cancel it, transition `HostState.Stopped`, and reconnect. The Agent's own `heartbeat` on the multipart boundary is the liveness signal; absence past the watchdog window is the frozen-peer detector.
|
||||
- **Reconnect / backoff:** geometric backoff (`MinBackoffMs`→`MaxBackoffMs`) on stream drop or connect failure, mirroring `ModbusReconnectOptions`. On reconnect, re-baseline via `/current` then resume `/sample` from the fresh `nextSequence`.
|
||||
- All capability calls flow through the existing Phase-6.1 `IDriverCapabilityInvoker` resilience pipeline (Read/Discover/Subscribe/Probe auto-retry); the per-call deadlines above are the driver-internal floor beneath that pipeline.
|
||||
- All capability calls flow through the existing Phase-6.1 `IDriverCapabilityInvoker` resilience pipeline (Read/Discover/Subscribe/Probe auto-retry); the per-call deadlines above are the driver-internal floor beneath that pipeline. **Subscribe-timeout interplay:** the pipeline's Subscribe timeout bounds only the `SubscribeAsync` call itself — the `/sample` stream-start handshake must complete within it, and `SubscribeAsync` returns once the stream is established; the long-lived pump then runs outside that timeout, guarded by the heartbeat watchdog above instead.
|
||||
|
||||
## 8. Test fixtures
|
||||
|
||||
1. **Canned XML unit fixtures (bulk of coverage, no network).** Capture one `MTConnectDevices` (probe) + a couple of `MTConnectStreams` (current + sample, incl. one with a forced `nextSequence` gap) from a demo agent into `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/`. Drive through the `IMTConnectAgentClient` fake to pin: `DiscoverAsync` tree shape + leaf `FullName==dataItemId`, `MTConnectDataTypeInference` table, observation indexing, `UNAVAILABLE→Bad`, CONDITION→state-word, multipart chunk framing, and ring-buffer re-baseline paging.
|
||||
1. **Canned XML unit fixtures (bulk of coverage, no network).** Capture one `MTConnectDevices` (probe) + a couple of `MTConnectStreams` (current + sample, incl. one with a forced `nextSequence` gap) from a demo agent into `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/`. Drive through the `IMTConnectAgentClient` fake to pin: `DiscoverAsync` tree shape + leaf `FullName==dataItemId`, `MTConnectDataTypeInference` table, observation indexing, `UNAVAILABLE→BadNoCommunication` (§3.3), CONDITION→state-word, multipart chunk framing, and ring-buffer re-baseline paging.
|
||||
2. **Dockerized `mtconnect/cppagent` (reproducible integration fixture).** Add `tests/.../Docker/docker-compose.yml` with the **`project: lmxopcua`** label on every service, seeded with a canned `Devices.xml` (+ an SHDR simulator or the built-in adapter), exposed on the shared docker host `10.100.0.35`; drive it via `lmxopcua-fix up mtconnect` + `sync`. Env-gated integration suite (`*.IntegrationTests`), skips cleanly when the fixture is down — the analog of the Modbus/S7 sims.
|
||||
3. **Public demo agent (manual live smoke only).** `https://demo.mtconnect.org/` (or NIST `smstestbed` `Devices.xml`) for a real-shape browse/read/stream smoke. Internet-dependent, not in CI.
|
||||
Live-verify the browse picker on docker-dev per the universal-browser discipline: open the `/uns` TagModal picker for an MTConnect driver, confirm the Device→Component→DataItem tree renders and a picked leaf commits `TagConfig.FullName = <dataItemId>` (Razor binding bugs pass unit tests — always `/run` it).
|
||||
|
||||
@@ -74,7 +74,7 @@ src/Drivers/
|
||||
| Path | Library | License | Notes |
|
||||
|---|---|---|---|
|
||||
| **CIP** | **`libplctag.NET`** (`<PackageReference Include="libplctag"/>`), attribute string `plc=omron-njnx` | native core dual MPL-2.0 / LGPL-2.1; .NET wrapper **MPL-2.0** | **Zero new license review** — the identical dependency AbCip already ships. |
|
||||
| **FINS** | **Hand-rolled** (no third-party dep) | our own code | FINS framing is ~200 lines, fully documented (W227 + Wireshark dissector); mirrors `ModbusTcpTransport`. |
|
||||
| **FINS** | **Hand-rolled** (no third-party dep) | our own code | FINS framing is ~200 lines, documented (W227 + Wireshark dissector) but research-sourced — trusted only after the P2 validation gate (§10); mirrors `ModbusTcpTransport`. |
|
||||
|
||||
**Avoid `HslCommunication`.** The maintained builds are **commercial-paid**; the "MIT" claim in
|
||||
casual sources refers to a stale, abandoned community fork
|
||||
@@ -187,6 +187,12 @@ programmer set its **Network Publish** attribute (`Publish Only`) in **Sysmac St
|
||||
it are invisible to any external client — including our reads. Document this loudly in
|
||||
`docs/drivers/Omron.md`: a CIP read of an unpublished variable fails at the wire, not a config bug.
|
||||
|
||||
**Operator documentation task (`docs/drivers/Omron.md`, P1):** alongside the Network-Publish note
|
||||
above, the driver doc must carry an explicit operator-facing warning that **`writable` defaults to
|
||||
`true` when omitted** from a tag's config (the convention inherited from AbCip's parser, §5.4). On
|
||||
a write-capable PLC driver, forgetting the field does not fail safe — it silently authors a
|
||||
writable node. Operators must set `"writable": false` explicitly on every read-only point.
|
||||
|
||||
---
|
||||
|
||||
## 4. Browse story — reconciliation with the universal browser
|
||||
@@ -337,6 +343,11 @@ via `EquipmentTagRefResolver`. For an **equipment tag**, `FullName` **is the raw
|
||||
`AbCipDataTypeExtensions`) maps to `DriverDataType`; `SecurityClass` = `Operate` when `writable` else
|
||||
`ViewOnly`.
|
||||
|
||||
**`writable` defaults to `true` when absent** — the parser inherits AbCip's convention, on both
|
||||
transport shapes (§5.2/§5.3). An Omron tag authored without an explicit `writable` field is
|
||||
therefore a **writable** node (`SecurityClass = Operate`). On a write-capable PLC driver this
|
||||
deserves an explicit operator-facing warning — see the operator-documentation item in §3.1.
|
||||
|
||||
---
|
||||
|
||||
## 6. Typed editor + validator
|
||||
@@ -464,14 +475,26 @@ The honest gap: **there is no free NJ/NX CIP simulator.**
|
||||
- **Primary CI surface — hand-rolled `IOmronCipRuntime` fake** (the same seam pattern AbCip uses via
|
||||
`IAbCipTagRuntime`/factory injection). Drives read/write/decode/array/BOOL-in-word/status-mapping
|
||||
unit tests with **no hardware and no container**. This is where correctness of the driver *body* is
|
||||
proven.
|
||||
proven. When mirroring AbCip's test structure, do **not** copy its integration-harness
|
||||
profile-class pattern that had the textual static-initializer-order bug (`AbServerProfile.cs`,
|
||||
fixed in `326b22a7`) — initialize profile constants free of cross-field static init-order
|
||||
dependence.
|
||||
- **Live gate — env-gated `Category=LiveIntegration`** against real NJ/NX hardware, skipping cleanly
|
||||
when the env var is absent (the pattern the HistorianGateway live suite uses). This is the **only**
|
||||
way to validate the real `plc=omron-njnx` libplctag wire path, Network-Publish behavior, and Omron
|
||||
string/array/UDT quirks. Env vars e.g. `OMRON_CIP_ENDPOINT` / `OMRON_CIP_TEST_TAG` /
|
||||
`OMRON_CIP_WRITE_SANDBOX_TAG`. **Flag as an infra-gated known limitation** in the driver doc + this
|
||||
design until an NJ/NX is on the bench. (Omron's own CX-Simulator is Windows-only + license-gated in
|
||||
CX-One — not a CI fixture.)
|
||||
way to validate the real `plc=omron-njnx` libplctag wire path. **P1 live-hardware gate checklist,
|
||||
in order:**
|
||||
1. **CIP string layout — FIRST.** Omron NJ/NX's string layout differs from Logix, and whether
|
||||
libplctag's `GetString` handles it correctly can only be proven on hardware. This is the
|
||||
likeliest wire surprise, and the `IOmronCipRuntime` fake cannot cover it — run a `String`
|
||||
read/write round-trip before anything else.
|
||||
2. Basic read/write round-trips per `OmronDataType` over the real `plc=omron-njnx` path.
|
||||
3. Network-Publish behavior (unpublished variable → wire failure, per §3.1).
|
||||
4. Array reads/writes + dotted UDT-member access (§3.1's live-gate item).
|
||||
|
||||
Env vars e.g. `OMRON_CIP_ENDPOINT` / `OMRON_CIP_TEST_TAG` /
|
||||
`OMRON_CIP_WRITE_SANDBOX_TAG` / `OMRON_CIP_STRING_SANDBOX_TAG`. **Flag as an infra-gated known
|
||||
limitation** in the driver doc + this design until an NJ/NX is on the bench. (Omron's own
|
||||
CX-Simulator is Windows-only + license-gated in CX-One — not a CI fixture.)
|
||||
|
||||
### FINS
|
||||
- **Primary — hand-rolled byte-level `IFinsTransport` fake** (mirror the Modbus `IModbusTransport`
|
||||
@@ -497,20 +520,34 @@ can't be proven in CI, so it should land when live NJ/NX hardware is available f
|
||||
|
||||
| Phase | Scope | Effort | Gate |
|
||||
|---|---|---|---|
|
||||
| **P1 — Omron CIP r/w (NJ/NX)** | `Driver.Omron` + `.Contracts`, `DriverType="Omron"`, `OmronCipTransport` on libplctag `plc=omron-njnx`; Connect/Read/Write/Subscribe(poll)/Probe; authored + equipment tags; typed editor + validator; factory + registration | Low–Medium (70–80% AbCip reuse) | hand-rolled runtime fake (CI) + env-gated live gate (hardware) |
|
||||
| **P2 — Omron FINS r/w (CJ/CS/CP)** | `IFinsTransport` codec (TCP+UDP, `0x0101`/`0x0102`, area-code table, word-swap) behind the same `IDriver` shell; memory-area TagConfig + editor fields (no browse) | Medium (net-new codec, no dep/license risk) | byte-level fake (CI) + OSS FINS sim container |
|
||||
| **P1 — Omron CIP r/w (NJ/NX)** | `Driver.Omron` + `.Contracts`, `DriverType="Omron"`, `OmronCipTransport` on libplctag `plc=omron-njnx`; Connect/Read/Write/Subscribe(poll)/Probe; authored + equipment tags; typed editor + validator; factory + registration | Low–Medium (70–80% AbCip reuse) | hand-rolled runtime fake (CI) + env-gated live gate (hardware; §9 checklist — string layout first) |
|
||||
| **P2 — Omron FINS r/w (CJ/CS/CP)** | `IFinsTransport` codec (TCP+UDP, `0x0101`/`0x0102`, area-code table, word-swap) behind the same `IDriver` shell; memory-area TagConfig + editor fields (no browse) | Medium (net-new codec, no dep/license risk) | byte-level fake (CI) + OSS FINS sim container + **FINS validation gate** (below) |
|
||||
|
||||
**P2 FINS validation gate (explicit):** every FINS wire specific in this design — the area-code
|
||||
table, the `0x0101`/`0x0102` command framing, the FINS/TCP node-address handshake, port 9600, and
|
||||
the bit-write behavior — is **research-sourced with no in-repo evidence**. The hand-rolled FINS
|
||||
framer is therefore **gated on validation against the Omron W227 FINS reference manual plus golden
|
||||
request/response vectors captured from live CJ/CS/CP hardware before it is trusted**; the captured
|
||||
vectors become the byte-level fake's fixtures (§9). Wire specifics stay parameterised per CPU
|
||||
family (the `FinsAreaCodeTable` keying already hedged in §5.3), so a manual/hardware discrepancy is
|
||||
a table correction, not a codec rewrite.
|
||||
| **P3 — Sysmac tag-export importer (CIP)** | AdminUI offline importer: parse Sysmac Studio variable export → author `OmronTagDefinition`s. **Not** a live browser. | Medium | parser unit tests on real export samples |
|
||||
|
||||
### Top risks
|
||||
1. **No free NJ/NX CIP simulator** (the headline). CIP wire correctness (string/array/UDT/
|
||||
Network-Publish) is provable only on live hardware behind the env-gated `LiveIntegration` suite →
|
||||
**infra-gated known limitation** until an NJ/NX is on the bench. The hand-rolled fake proves the
|
||||
driver *body*, not the *wire*.
|
||||
driver *body*, not the *wire* — and the NJ/NX-vs-Logix **string layout** is the likeliest wire
|
||||
surprise, so it is the *first* item on the P1 live-gate checklist (§9).
|
||||
2. **CIP browse is not free reuse.** libplctag's `@tags` walker returns `ErrorUnsupported` on Omron
|
||||
NJ/NX; `SupportsOnlineDiscovery=false`, the universal browser doesn't apply, and browse ships as
|
||||
an offline Sysmac-export importer (P3), with online browse deferred to a later research phase.
|
||||
3. **FINS per-family variance** in memory-area codes + multi-word ordering — contained by the
|
||||
CPU-family-parameterised `FinsAreaCodeTable` + per-tag `wordSwap`.
|
||||
3. **FINS wire specifics are research-sourced, not evidence-backed.** The area codes,
|
||||
`0x0101`/`0x0102` commands, FINS/TCP handshake, port 9600, and bit-write behavior have no
|
||||
in-repo precedent — the hand-rolled framer is gated on the P2 validation gate (W227 manual +
|
||||
golden vectors from live hardware) before it is trusted. Per-family variance in memory-area
|
||||
codes + multi-word ordering is contained by the CPU-family-parameterised `FinsAreaCodeTable` +
|
||||
per-tag `wordSwap`.
|
||||
4. **Enum-serialization trap** — every config JSON surface must attach `JsonStringEnumConverter`, or
|
||||
AdminUI-authored Omron configs fault the driver. Live-`/run` verify catches it; unit tests don't.
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# v3 — Kepware-style Raw tree + UNS projection (two-subtree address space)
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Design approved (brainstorming complete) — ready for implementation planning
|
||||
**Status:** Design approved (brainstorming complete) — **revised 2026-07-15 after a
|
||||
codebase-grounded design review** (identity contract added, delegate mechanism reworded,
|
||||
`{{equip}}` redesigned, Device-table materialization scoped, Batch 1 re-scoped, ScadaBridge
|
||||
claim corrected, `WriteIdempotent` restored), then reconciled with the pre-v3 universal
|
||||
discovery browser (`2026-07-15-universal-discovery-browser-design.md`). **All open items
|
||||
resolved 2026-07-15** (live historian probe, SDK source spike, code audits; see
|
||||
[Open items](#open-items)). Ready for implementation planning.
|
||||
**Version:** Next major version (v3). The project has no git tags; "v2" is the conceptual
|
||||
current major (the Akka-fused rebuild, `docs/v2/`), so this is v3.
|
||||
|
||||
@@ -21,7 +27,10 @@ config data is preserved; dev rigs are re-seeded against a fresh schema.
|
||||
|
||||
- **Raw is the source of truth; UNS references it.** A tag is authored once, under a driver
|
||||
in the Raw tree. UNS holds references that re-point existing raw tags into equipment. One
|
||||
tag, two views — rename/retype in Raw propagates automatically.
|
||||
tag, two views — rename/retype in Raw propagates automatically *within the server*.
|
||||
(Rename is **not** free downstream: it changes the RawPath and therefore the NodeId,
|
||||
the default historian tagname, script literals, and external raw bindings — see
|
||||
[Error handling / edge cases](#error-handling--edge-cases).)
|
||||
- **Raw hierarchy:** `Cluster → Folder(s, nestable) → Driver → Device → TagGroup(s, nestable) → Tag`.
|
||||
Full Kepware parity.
|
||||
- **Raw is global, cluster-rooted**, mirroring the existing global `/uns` page (which is
|
||||
@@ -30,13 +39,26 @@ config data is preserved; dev rigs are re-seeded against a fresh schema.
|
||||
- **UNS authoring is reference-only (clean break).** The Equipment editor no longer binds a
|
||||
driver or defines `TagConfig`; it references existing raw tags (with an optional UNS
|
||||
display-name override) and continues to host VirtualTags + scripted alarms.
|
||||
- **OPC UA exposure:** two namespaces — `ns=Raw` (stable device-path NodeIds, **always on**)
|
||||
and `ns=UNS` (equipment-path NodeIds). UNS nodes `Organizes →` their backing raw nodes.
|
||||
- **UNS ↔ Raw linkage = delegate/redirect (not value-copy).** A UNS node is a thin variable
|
||||
whose reads/writes/subscriptions are delegated to the backing raw node's single value
|
||||
source. No double-buffering, no drift.
|
||||
- **OPC UA exposure:** two namespaces — `ns=Raw` (device-path NodeIds, stable **absent
|
||||
rename**, always on) and `ns=UNS` (equipment-path NodeIds). UNS nodes `Organizes →` their
|
||||
backing raw nodes.
|
||||
- **UNS ↔ Raw linkage = single value source, fanned out (not delegation, not independent
|
||||
buffering).** The driver publish path remains the *only* writer of tag values. A UNS node
|
||||
is a second NodeId registered against the same driver ref in the existing 1:N value
|
||||
fan-out map (`DriverHostActor._nodeIdByDriverRef`) and the write inverse map
|
||||
(`_driverRefByNodeId`). Both node states hold the value the single publish path wrote —
|
||||
no independent buffer exists, so no drift is possible. *(Reworded from the original
|
||||
"delegate/redirect": the OPC Foundation stack as used here has no OnRead delegation path —
|
||||
monitored items sample the stored `BaseDataVariableState.Value` — and the fan-out map is
|
||||
already explicitly 1:N. The intent, one source of truth, is delivered by the existing
|
||||
machinery.)*
|
||||
- **Tag creation is capability-based:** manual entry + CSV import/export for **all** drivers;
|
||||
live **Browse** only for drivers that advertise a browser (OpcUaClient, Galaxy today).
|
||||
live **Browse** gated by the **two-tier browser resolution** landing before v3
|
||||
(`docs/plans/2026-07-15-universal-discovery-browser-design.md`): bespoke `IDriverBrowser`
|
||||
first (OpcUaClient, Galaxy), else the universal `DiscoveryDriverBrowser` when the driver's
|
||||
`ITagDiscovery.SupportsOnlineDiscovery` is true (AbCip, TwinCAT, FOCAS at v3 time; future
|
||||
discovery drivers for free), else manual entry. Flat-address drivers (Modbus, S7, AbLegacy)
|
||||
stay manual-entry by nature.
|
||||
- **Calculated tags — two-tier:**
|
||||
- *Signal-level* calcs = raw tags bound to a **`Calculation` pseudo-driver** whose value
|
||||
source is the existing Roslyn scripting engine. They reuse the entire raw pipeline
|
||||
@@ -53,19 +75,81 @@ config data is preserved; dev rigs are re-seeded against a fresh schema.
|
||||
|
||||
- A **`ServerCluster`** (PK `ClusterId`) owns a **flat list** of `DriverInstance` rows — there
|
||||
is **no folder/grouping** construct for drivers today.
|
||||
- Drivers have `Device` rows (multi-device drivers only) and `PollGroup` rows.
|
||||
- Drivers have `Device` rows (multi-device drivers only) and `PollGroup` rows. **Caveat
|
||||
(review finding):** the `Device` EF table is **vestigial** — no production code path writes
|
||||
it; the "multi-device" drivers (AbCip, AbLegacy, TwinCAT, FOCAS) embed a `Devices[]` array
|
||||
inside `DriverConfig` JSON, and single-endpoint drivers embed `Host`/`EndpointUrl` in
|
||||
`DriverConfig`. v3 materializes the table for real — see the Device entry below.
|
||||
- **`Tag`** binds to a driver via `DriverInstanceId` + schemaless `TagConfig` JSON and lives
|
||||
either under an `Equipment` (`EquipmentId`) or at a namespace-root `FolderPath`.
|
||||
- **Tag identity today is `FullName`, which is *derived*, not stored**: `TagConfigIntent.Parse`
|
||||
re-derives it from `TagConfig` at every seam. Galaxy/OpcUaClient write an explicit
|
||||
`"FullName"` key (dotted/opaque ref); **the six protocol drivers do not — their FullName is
|
||||
the entire raw `TagConfig` JSON blob.** That one string is simultaneously the driver
|
||||
wire-ref (`EquipmentTagRefResolver` key), the value fan-out key, the write-routing target,
|
||||
the historian default tagname + mux interest key, the native-alarm `ConditionId` (both
|
||||
directions), and the `ctx.GetTag("…")` script path. v3 replaces it — see
|
||||
[v3 identity contract](#v3-identity-contract-rawpath).
|
||||
- The **UNS tree** (Enterprise → Cluster → Area → Line → Equipment → Tag/VirtualTag) is the
|
||||
**only** authored tree and is what the address space exposes.
|
||||
**only** authored tree and is what the address space exposes. NodeIds are
|
||||
**logical-id-based** (`{EquipmentId}/{FolderPath}/{Name}` via `EquipmentNodeIds`), in a
|
||||
**single custom OPC UA namespace** (`https://zb.com/otopcua/ns`).
|
||||
- Driver authoring is a page-per-type flow: `/clusters/{id}/drivers` → `DriverTypePicker` →
|
||||
8 typed Razor pages (`ModbusDriverPage`, …) dispatched by `DriverEditRouter._componentMap`.
|
||||
- `DriverType` dispatch is duplicated in ~3 hard-coded places with pre-existing string
|
||||
mismatches: `TwinCAT`/`TwinCat`, `FOCAS`/`Focas`, `GalaxyMxGateway`/`galaxy`.
|
||||
- `DriverType` dispatch is duplicated in ≥4 hard-coded places with pre-existing string
|
||||
mismatches: `TwinCAT`/`TwinCat`, `FOCAS`/`Focas`, `GalaxyMxGateway`/`galaxy`
|
||||
(`DriverEditRouter`, `TagConfigEditorMap`, `TagConfigValidator`,
|
||||
`EquipmentTagConfigInspector`).
|
||||
- Runtime: Akka actors — `DriverHostActor` (per node/role) → `DriverInstanceActor` (per
|
||||
driver) — bind driver values through `EquipmentTagRefResolver`/`TagConfig.FullName`.
|
||||
- Address-space pipeline: `AddressSpace*` (Composer → Composition → Planner → Plan → Applier).
|
||||
|
||||
## v3 identity contract (RawPath)
|
||||
|
||||
The single most load-bearing change in v3. The blob-as-FullName convention is replaced by one
|
||||
canonical identity string used at **every** seam:
|
||||
|
||||
**`RawPath`** — the cluster-scoped raw device path, slash-separated:
|
||||
`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`. It is simultaneously:
|
||||
|
||||
| Seam | Today's key | v3 key |
|
||||
|---|---|---|
|
||||
| `ns=Raw` NodeId | — (no raw tree) | `s=<RawPath>` |
|
||||
| Driver wire-ref (`EquipmentTagRefResolver`) | FullName (= TagConfig blob for 6/8 drivers) | RawPath |
|
||||
| Value fan-out (`_nodeIdByDriverRef`) | `(DriverInstanceId, FullName)` | `(DriverInstanceId, RawPath)` |
|
||||
| Write routing (`WriteAttribute`) | FullName | RawPath |
|
||||
| Historian default tagname + `MuxRef` | FullName | RawPath (override via `historianTagname` unchanged) |
|
||||
| Native-alarm `ConditionId` | FullName | RawPath |
|
||||
| Script `ctx.GetTag` path / mux dependency ref | FullName | RawPath |
|
||||
|
||||
**Driver-side resolution changes in all 8 drivers.** The `EquipmentTagRefResolver`'s
|
||||
`_byName` branch becomes the primary path: each deploy hands the driver its authored raw tags
|
||||
keyed by RawPath, and the driver looks the ref up to obtain the `TagConfig`-derived
|
||||
definition. The per-driver `EquipmentTagParser` blob-fallback (e.g. S7's leading-`{`
|
||||
convention) is retired; ad-hoc/unparsed refs are no longer a supported input. This is
|
||||
per-driver work and is scoped into Batch 1 (see Build plan).
|
||||
|
||||
**`TagConfig` no longer carries identity.** For Galaxy/OpcUaClient the address
|
||||
(`tag_name.AttributeName` / upstream node-id) stays inside `TagConfig` as an ordinary
|
||||
driver-specific address field — it is what the driver *dials*, not what the system *keys on*.
|
||||
`TagConfigIntent` sheds its FullName-derivation role.
|
||||
|
||||
**TagConfig key normalization (greenfield, decided):** with identity gone from `TagConfig`,
|
||||
two legacy conventions are cleaned up: (a) the PascalCase `"FullName"` key (OpcUaClient,
|
||||
Galaxy — the case-sensitive `TagConfigIntent` contract) becomes an ordinary camelCase
|
||||
address key — `nodeId` for OpcUaClient, `attributeRef` for Galaxy; (b) the
|
||||
`deviceHostAddress` key (AbCip, AbLegacy, TwinCAT, FOCAS) is **dropped entirely** — the
|
||||
tag's `DeviceId` FK now owns device placement, so the key is structurally redundant.
|
||||
|
||||
**Historian tagname limit — live-verified 2026-07-15 against the real AVEVA historian**
|
||||
(wonder-sql-vd03 via the local HistorianGateway, `grpcurl` probe): tagnames are accepted up
|
||||
to **255 characters** (256 is rejected — `EnsureTag returned false`); `/`, space, `-`, `_`,
|
||||
and `.` are all accepted; path-shaped names round-trip **byte-exactly** through
|
||||
`EnsureTags → WriteLiveValues → ReadRaw` with GOOD (192) quality, and wildcard browse works.
|
||||
**Policy:** a historized tag whose *effective* historian tagname (override else RawPath)
|
||||
exceeds 255 characters is a **`DraftValidator` deploy error** telling the author to set a
|
||||
shorter `historianTagname` override — never a silent truncation.
|
||||
|
||||
## Data model (greenfield schema)
|
||||
|
||||
### Raw side
|
||||
@@ -73,14 +157,29 @@ config data is preserved; dev rigs are re-seeded against a fresh schema.
|
||||
FK, nullable `ParentRawFolderId` (null = root under cluster), `Name`, `SortOrder`.
|
||||
- **`DriverInstance`** *(reshaped)* — gains nullable `RawFolderId` (null = cluster root).
|
||||
Keeps `ClusterId`, `DriverType`, `DriverConfig`, `ResilienceConfig`. **Drops** per-driver
|
||||
`NamespaceId` (namespaces become implicit — see Address space).
|
||||
- **`Device`** *(kept, now universal)* — `DeviceId` PK, `DriverInstanceId` FK, `Name`,
|
||||
`DeviceConfig` JSON. **Every driver has ≥1 device**; single-endpoint drivers (S7, TwinCAT,
|
||||
FOCAS, OpcUaClient, Galaxy, Calculation) get an auto-created *default* device.
|
||||
`NamespaceId`. The **`Namespace` entity, `NamespaceKind`, and `NamespaceKindCompatibility`
|
||||
are retired outright** — the two OPC UA namespaces are implicit (see Address space).
|
||||
`NamespaceKind.Simulated` **verified vestigial 2026-07-15**: it is a reserved enum member,
|
||||
a "reserved" dropdown option in `NamespaceEdit.razor`, and a `NamespaceKindCompatibility`
|
||||
flag documented "future Simulated namespace (replay driver — not in v2.0)" — no driver
|
||||
declares it, no seed populates it, and the composer filters on `Kind == Equipment` only.
|
||||
Retired with the entity; a future replay/simulator driver is simply another Raw
|
||||
`DriverType`, which the v3 model handles natively.
|
||||
- **`Device`** *(materialized for real, now universal)* — `DeviceId` PK, `DriverInstanceId`
|
||||
FK, `Name`, `DeviceConfig` JSON. **Every driver has ≥1 device**; single-endpoint drivers
|
||||
(S7, TwinCAT, FOCAS, OpcUaClient, Galaxy, Calculation) get an auto-created *default*
|
||||
device. **Endpoint/connection settings move from `DriverConfig` into `DeviceConfig`** for
|
||||
all drivers (the Kepware channel/device split: `DriverConfig` keeps protocol/channel-level
|
||||
settings; `DeviceConfig` carries host/endpoint + per-device settings). This retires the
|
||||
embedded `Devices[]` arrays in the four multi-device drivers' `DriverConfig` and reworks
|
||||
their options schemas, Razor pages, `DeviceConfigIntent`, the artifact device-host map, and
|
||||
runtime driver spawning. *(Review finding: this is real migration work, not a "kept" table
|
||||
— it is explicitly scoped into Batches 1–2.)*
|
||||
- **`TagGroup`** *(new)* — nestable tag folder under a device. `TagGroupId` PK, `DeviceId` FK,
|
||||
nullable `ParentTagGroupId`, `Name`, `SortOrder`.
|
||||
- **`Tag`** *(reshaped → raw-only)* — `TagId` PK, `DeviceId` FK (required), nullable
|
||||
`TagGroupId`, `Name`, `DataType`, `AccessLevel`, nullable `PollGroupId`, `TagConfig` JSON.
|
||||
`TagGroupId`, `Name`, `DataType`, `AccessLevel`, **`WriteIdempotent`** *(retained — dropping
|
||||
it would regress the R2 resilience fix)*, nullable `PollGroupId`, `TagConfig` JSON.
|
||||
**Drops `EquipmentId` and `FolderPath`.**
|
||||
- **`PollGroup`** *(unchanged)* — driver-scoped polling groups.
|
||||
|
||||
@@ -90,6 +189,22 @@ config data is preserved; dev rigs are re-seeded against a fresh schema.
|
||||
- **`UnsTagReference`** *(new)* — the projection. `EquipmentId` FK, `TagId` FK (→ raw tag),
|
||||
nullable `DisplayNameOverride`, `SortOrder`.
|
||||
- **`VirtualTag`, `ScriptedAlarm`** *(unchanged)* — per-equipment, UNS-native.
|
||||
- The orphaned `EquipmentImportBatch`/`EquipmentImportRow` tables (never wired to any service
|
||||
or UI) are **dropped** in the greenfield schema.
|
||||
|
||||
### Uniqueness + naming rules *(new — required by path-shaped NodeIds)*
|
||||
- Sibling-name uniqueness at every raw level: `RawFolder (ClusterId, ParentRawFolderId, Name)`,
|
||||
`DriverInstance (ClusterId, RawFolderId, Name)`, `Device (DriverInstanceId, Name)`,
|
||||
`TagGroup (DeviceId, ParentTagGroupId, Name)`, `Tag (DeviceId, TagGroupId, Name)` — all
|
||||
unique (filtered indexes for the nullable parents), mirroring today's careful unique-index
|
||||
conventions.
|
||||
- Raw names forbid `/` (and leading/trailing whitespace) — validated at authoring; `/` is the
|
||||
RawPath separator and would corrupt NodeIds.
|
||||
- **UNS effective-leaf uniqueness:** within an equipment, the *effective* name
|
||||
(`DisplayNameOverride` else the raw tag's `Name`) must be unique **across references,
|
||||
VirtualTags, and ScriptedAlarms**. Enforced at authoring (service-level check) **and** at
|
||||
deploy time in `DraftValidator` — the deploy gate is what catches rename-induced collisions
|
||||
(a raw rename can create a clash the authoring check never saw).
|
||||
|
||||
### Integrity rules
|
||||
- Deleting a raw `Tag` is **blocked while any `UnsTagReference` points at it**; the UI names
|
||||
@@ -110,10 +225,21 @@ New global page **`/raw`** (peer to `/uns`), a lazy, cluster-rooted project tree
|
||||
- **TagGroup** — New group / Add tags ▸ / Rename / Delete.
|
||||
- **Tag** — Edit / Delete.
|
||||
|
||||
**New AdminUI infrastructure (review finding — budget for it in Batch 2):**
|
||||
- **Right-click context menus do not exist anywhere in the Blazor app** (the only
|
||||
`ContextMenu` is in the Avalonia desktop client). A reusable `oncontextmenu` component is
|
||||
new build, including keyboard/touch fallback (an explicit "⋯" affordance per node so the
|
||||
menu is reachable without right-click).
|
||||
- **Lazy tree loading is plumbed but never exercised**: `UnsNode.HasLazyChildren`/`Loading`
|
||||
exist, but `GlobalUns` eager-loads the whole structure. `/raw` wires lazy loading for the
|
||||
first time (per-device tag counts can be large).
|
||||
|
||||
**Reuse (not rebuild):**
|
||||
- *Configure driver* hosts the existing typed driver forms (`ModbusDriverPage` et al.)
|
||||
refactored into embeddable form bodies inside a `DriverConfigModal`. Only the page
|
||||
shell/routing changes.
|
||||
refactored into embeddable form bodies inside a `DriverConfigModal`. The shared inner
|
||||
sections already exist (`DriverFormShell`, `DriverIdentitySection`, `DriverTestConnectButton`,
|
||||
`DriverResilienceSection`); what moves out of each ~500-line page is the page-owned
|
||||
`@page` route, `EditForm`, ClusterNav header, DB load, and post-save navigation.
|
||||
- *Test connect* uses the existing `DriverTestConnectButton` + `IDriverProbe` path (transient
|
||||
config, no persistence).
|
||||
- *Edit tag* uses the existing per-driver `TagConfig` editors via `TagConfigEditorMap`.
|
||||
@@ -121,13 +247,41 @@ New global page **`/raw`** (peer to `/uns`), a lazy, cluster-rooted project tree
|
||||
**Add tags ▸ (capability-based)** on a Device or TagGroup:
|
||||
- **Manual entry** — grid: name, datatype, access + the driver-typed `TagConfig` editor.
|
||||
- **Import/Export CSV** — columns `Name, TagGroupPath, DataType, AccessLevel,
|
||||
<driver-specific TagConfig columns>`; staged-then-commit like today's
|
||||
`EquipmentImportBatch`. Export round-trips the same shape.
|
||||
- **Browse device…** — enabled only when the driver's factory advertises a browser
|
||||
(`DriverBrowseTree` + `IBrowserSessionService`); grayed out with a tooltip otherwise.
|
||||
<driver-typed columns…>, TagConfigJson`. Per-driver column dictionaries are **decided**
|
||||
(see the appendix "CSV column dictionaries"); the nested `alarm` object rides flat
|
||||
dotted sub-columns (`alarm.alarmType`, `alarm.severity`, `alarm.historizeToAveva`);
|
||||
anything else non-flat rides the `TagConfigJson` fallback column. Staged
|
||||
parse → review grid → commit (RFC 4180 parser). Export round-trips the same shape.
|
||||
*(Review correction: the previously cited `EquipmentImportBatch` staged flow is **orphaned
|
||||
dead code** — never referenced by any service or UI — and today's live equipment import is
|
||||
a naive non-staged, comma-split, metadata-only path with **no tag import at all**. The tag
|
||||
CSV importer is a from-scratch feature, including an RFC 4180 parser; the orphaned tables
|
||||
are dropped, not extended.)*
|
||||
- **Browse device…** — enabled per the two-tier resolution in `BrowserSessionService`
|
||||
(bespoke browser → universal `DiscoveryDriverBrowser` when `SupportsOnlineDiscovery` →
|
||||
manual entry; see `docs/plans/2026-07-15-universal-discovery-browser-design.md`, which
|
||||
lands **before v3** against the current picker); grayed out with a tooltip when neither
|
||||
tier applies. **v3 re-targets the browse commit** (the session/tree layer —
|
||||
`IBrowseSession`, `BrowseSessionRegistry`, `DriverBrowseTree` — is reused as-is):
|
||||
- *Commit contract:* multi-selected leaves become **raw `Tag` rows** under the target
|
||||
Device/TagGroup — `Name` from the leaf's browse name, `DataType` from
|
||||
`DriverAttributeInfo`, and the leaf's driver reference (`attr.FullName`) written into
|
||||
the **driver-typed `TagConfig` as an address field**. Under the v3 identity contract
|
||||
that value is what the driver *dials*, not the system identity — the tag's identity is
|
||||
its RawPath. (The universal-browser doc's "commit `TagConfig.FullName`" wording
|
||||
describes the pre-v3 picker; see its v3 forward note.)
|
||||
- *Folder mirroring:* the captured browse folder nesting is offered as an opt-in
|
||||
"create matching tag-groups" toggle — browse folders map naturally onto nested
|
||||
`TagGroup`s.
|
||||
- *Config input:* v3 moves endpoints into `DeviceConfig`, so the browse modal passes the
|
||||
**merged Driver + Device config** as the browser's `configJson` (the universal browser
|
||||
connects a real ephemeral driver instance; it needs the device endpoint).
|
||||
|
||||
**Cleanup folded in:** canonicalize the `DriverType` strings to a single source of truth
|
||||
(the tree dispatches on driver type in one place instead of three).
|
||||
**Cleanup folded in:** canonicalize the `DriverType` strings to a single source of truth —
|
||||
one constants class consumed by `DriverEditRouter`, `TagConfigEditorMap`,
|
||||
`TagConfigValidator`, and `EquipmentTagConfigInspector` (the four drifted dispatch maps),
|
||||
with the driver factories' `DriverTypeName` as the authority (`TwinCAT`, `FOCAS`,
|
||||
`GalaxyMxGateway`).
|
||||
|
||||
## UNS Equipment — reference-only
|
||||
|
||||
@@ -138,67 +292,161 @@ The `/uns` tree is unchanged; the **Equipment Tags tab** becomes a reference lis
|
||||
to pull many raw tags at once.
|
||||
- Each row is a `UnsTagReference`: shows the raw path, inherited datatype/access (read-only),
|
||||
and an optional display-name override.
|
||||
- **VirtualTags and scripted alarms** stay exactly as they are.
|
||||
- **VirtualTags and scripted alarms** stay as they are, **except the `{{equip}}` seam, which
|
||||
is redesigned** (see Scripting below) — the current derivation (shared first-dot prefix of
|
||||
child-tag FullNames) cannot survive reference-only equipment.
|
||||
- The old `ImportEquipmentModal` drops its `DriverInstanceId` column (equipment no longer
|
||||
carries a driver).
|
||||
- *Deferred to a follow-up:* pattern/CSV-based auto-linking of raw → UNS.
|
||||
|
||||
## Scripting — paths and `{{equip}}` under v3
|
||||
|
||||
- **`ctx.GetTag("…")` takes a RawPath.** The mux dependency refs, the read-cache keys, and
|
||||
the Monaco tag catalog all move to RawPath (the catalog lists raw tags by RawPath;
|
||||
VirtualTags stay by bare name).
|
||||
- **`{{equip}}` is redefined as reference-relative resolution** (replaces prefix
|
||||
derivation): `ctx.GetTag("{{equip}}/<RefName>")` resolves **through the equipment's
|
||||
`UnsTagReference` rows by effective name** to the backing RawPath, at the same two compose
|
||||
seams that substitute today (`AddressSpaceComposer` + `DeploymentArtifact`). An unresolved
|
||||
`<RefName>` is a **deploy-time validation error** (better than today's silent
|
||||
null-base no-substitution). This decouples equipment scripts from raw topology — exactly
|
||||
what per-equipment instantiation wants — and gives Monaco a well-defined
|
||||
equipment-relative completion list (the reference effective names).
|
||||
- Alarm `{TagPath}` message tokens follow the same rule.
|
||||
|
||||
## OPC UA address space + runtime binding
|
||||
|
||||
Runs through the existing `AddressSpace*` pipeline. **Two namespaces per server** (each
|
||||
running cluster exposes only its own):
|
||||
|
||||
- **`ns=Raw`** — NodeIds are stable device paths:
|
||||
`s=<Folder/…>/<Driver>/<Device>/<Group/…>/<Tag>`. Folders/drivers/devices/tag-groups are
|
||||
`Object`/`Folder` nodes; tags are `Variable` nodes.
|
||||
- **`ns=UNS`** — NodeIds are equipment paths: `s=<Area>/<Line>/<Equipment>/<TagName>`.
|
||||
- **`ns=Raw`** — NodeIds are `s=<RawPath>`. Folders/drivers/devices/tag-groups are
|
||||
`Object`/`Folder` nodes; tags are `Variable` nodes. Stable absent rename.
|
||||
- **`ns=UNS`** — NodeIds are equipment paths: `s=<Area>/<Line>/<Equipment>/<EffectiveName>`.
|
||||
Area/Line/Equipment are folders; each `UnsTagReference` is a UNS variable node.
|
||||
|
||||
**UNS ↔ Raw = delegate/redirect:** the UNS node is a thin variable whose reads/writes/
|
||||
subscriptions delegate to the backing raw node's single value source; an `Organizes`
|
||||
**UNS ↔ Raw = single source, fan-out** (see Decisions): the raw and UNS variable nodes are
|
||||
two NodeIds registered against the same `(DriverInstanceId, RawPath)` in the existing 1:N
|
||||
forward map; the write inverse map gains the UNS NodeId → same ref entry. An `Organizes`
|
||||
reference UNS → Raw makes the linkage browsable.
|
||||
|
||||
**Runtime binding flows through Raw only.** Driver-host actors bind driver values to **raw**
|
||||
tag nodes (via `EquipmentTagRefResolver`/`FullName`, now resolving a raw device path). UNS
|
||||
nodes never bind a driver directly. Writes to a UNS node route to the raw node's driver (same
|
||||
`WriteOperate` gating). HistoryRead, native alarms, and continuous historization all attach
|
||||
at the **raw** node and are visible through the UNS reference.
|
||||
tag nodes via the RawPath contract. UNS nodes never bind a driver directly. Writes to a UNS
|
||||
node route to the raw node's driver (same `WriteOperate` gating).
|
||||
|
||||
**Cross-repo impact:** ScadaBridge's Data Connection Layer keeps its UNS/equipment bindings
|
||||
(same equipment paths) and *gains* the option to bind raw device paths directly. Record this
|
||||
v3 contract change in the scadaproj umbrella index (`../scadaproj/CLAUDE.md`).
|
||||
**Historian via UNS (explicit registration, not automatic):** the raw node carries
|
||||
`Historizing=true` and the `_historizedTagnames` entry; the UNS reference node is registered
|
||||
with the **same** historian tagname and the HistoryRead access bit, so HistoryRead works
|
||||
against either NodeId. The mux `HistorizedTagRef` stays single (keyed by RawPath) — no double
|
||||
historization.
|
||||
|
||||
**Alarms/events across the two namespaces:** a native alarm condition is a **single
|
||||
condition instance** materialized at the raw tag (parent = its device/group folder, which is
|
||||
promoted to an event notifier, as equipment folders are today). Each *referencing*
|
||||
equipment's UNS folder is **also** registered as an event notifier surfacing that same
|
||||
condition, so a client subscribed at the equipment sees native alarm events without touching
|
||||
`ns=Raw`. `/alerts` row identity: **one row per condition**, primary identity = RawPath +
|
||||
condition NodeId; `AlarmTransitionEvent` gains the (possibly empty) list of referencing
|
||||
equipment paths for display. Scripted alarms stay per-equipment in `ns=UNS`, unchanged.
|
||||
|
||||
*SDK spike resolved (2026-07-15, verified against the vendored UA-.NETStandard source):*
|
||||
**native multi-notifier wiring is supported — use it; do NOT duplicate `ReportEvent`.**
|
||||
`NodeState.ReportEvent` bubbles through the single HasComponent parent chain **plus every
|
||||
inverse entry in the node's notifier list**, so one condition fans one event to any number
|
||||
of notifier roots. Per additional (non-parent) root folder:
|
||||
|
||||
```csharp
|
||||
alarm.AddNotifier(SystemContext, null, isInverse: true, extraFolder); // upward bubble
|
||||
extraFolder.AddNotifier(SystemContext, null, isInverse: false, alarm); // downward AreEventsMonitored
|
||||
EnsureFolderIsEventNotifier(extraFolder); // SubscribeToEvents + AddRootNotifier
|
||||
```
|
||||
|
||||
(the exact pattern the SDK's own `TestDataNodeManager` quickstart exercises). A single
|
||||
`alarm.ReportEvent(...)` then reaches every wired root; Server-object subscribers get
|
||||
**one** copy because the shared `InstanceStateSnapshot` reference is deduped by the event
|
||||
queue. The duplicate-report fallback is rejected: per-root re-reporting mints distinct
|
||||
`EventId`s/snapshots, defeating the dedup (double delivery to Server-object subscribers) and
|
||||
breaking Part 9 ack correlation. Two implementation obligations: **teardown symmetry**
|
||||
(`RemoveNotifier(..., bidirectional: true)` alongside the existing `RemoveRootNotifier`
|
||||
paths on rebuild/subtree-removal, tracked like `_notifierFolders`, or inverse-notifier
|
||||
entries leak across redeploys) and a Batch 4 live check that a Server-object subscriber
|
||||
receives exactly one copy per transition while folder-scoped subscribers in each namespace
|
||||
receive the condition's events.
|
||||
|
||||
**Sink interface changes carry the forwarding trap:** `IOpcUaAddressSpaceSink` /
|
||||
`ISurgicalAddressSpaceSink` methods gain a Raw/UNS discriminator (or NodeIds become
|
||||
namespace-qualified). Every new or changed method **must** forward through
|
||||
`DeferredAddressSpaceSink`, and `DeferredSinkForwardingReflectionTests` must be extended to
|
||||
cover the new surface — this is part of Batch 4's definition of done, not an afterthought.
|
||||
|
||||
**Cross-repo impact (corrected + sized 2026-07-15):** ScadaBridge's Data Connection Layer
|
||||
does **not** keep its bindings — today's NodeIds are logical-id-based
|
||||
(`{EquipmentId}/{FolderPath}/{Name}`), v3's are name-path-based in new namespaces.
|
||||
**Every ScadaBridge binding re-binds.** The re-bind is a *data* migration, not a code
|
||||
rewrite: ScadaBridge treats the reference as an opaque `NodeId.Parse`-able string held in
|
||||
exactly **two DB columns** (`TemplateAttribute.DataSourceReference`,
|
||||
`InstanceConnectionBinding.DataSourceReferenceOverride`) plus native-alarm source refs,
|
||||
heartbeat `TagPath`s, and any exported Transport bundles — nothing parses the shape.
|
||||
The fragile part: stored references hard-code the **namespace index** (`ns=2;…`) and
|
||||
ScadaBridge stores no namespace URI, so v3's new namespaces invalidate every stored index.
|
||||
**Cutover plan:** (1) v3.0 lands and rigs re-seed; (2) ScadaBridge re-authors bindings via
|
||||
its existing OPC UA picker (greenfield means no reliable automatic old→new mapping);
|
||||
(3) recommend ScadaBridge move to `nsu=`-qualified references while re-binding, so future
|
||||
namespace-table changes can't strand it again (ScadaBridge-side follow-up); (4) update the
|
||||
scadaproj umbrella index when the contract lands.
|
||||
|
||||
## Calculated tags (two-tier)
|
||||
|
||||
- **`Calculation` pseudo-driver** *(new DriverType)* — signal-level calc tags are raw tags
|
||||
bound to this driver. Registered in `DriverTypeRegistry` like any driver; a single default
|
||||
`Engine` device; the tag's `TagConfig` holds a **script reference** instead of a register
|
||||
address, and its value source is the existing Roslyn scripting engine (`ctx.GetTag`,
|
||||
publish gate, Monaco editor). Wire it into the driver-host value fan-out. It inherits
|
||||
folders, CSV, historization, native alarms, and UNS references for free.
|
||||
`Engine` device; the tag's `TagConfig` holds a **script reference** (`ScriptId`) plus the
|
||||
**trigger model mirrored from VirtualTags**: `changeTriggered` (re-evaluate on any input
|
||||
change) and/or `timerIntervalMs` (≥ 50), at least one required. Inputs are `ctx.GetTag`
|
||||
RawPath literals resolved through the same dependency mux; the value source is the existing
|
||||
Roslyn engine (publish gate, Monaco editor, script-log all reused). **Cycle rule:** the
|
||||
calc-tag dependency graph (calc → calc edges) is checked at deploy; a cycle is a
|
||||
`DraftValidator` error. No PollGroups; the driver's `IDriverProbe` is a script-compile
|
||||
check. It inherits folders, CSV, historization, native alarms, and UNS references for free.
|
||||
- **UNS VirtualTags** *(unchanged)* — retained for equipment-context computations that need
|
||||
`{{equip}}` templating / per-equipment instantiation.
|
||||
- **Author rule:** signal-level → Raw (`Calculation` driver); equipment-context or
|
||||
cross-equipment → UNS VirtualTag.
|
||||
- **Mini-design complete:** `docs/plans/2026-07-15-calculation-driver-mini-design.md`
|
||||
(2026-07-15) — driver shape (`IDriver + ISubscribable + IReadable`, not writable/browsable),
|
||||
the new `IDependencyConsumer` capability interface feeding the driver from the existing
|
||||
per-node dependency mux, shared `Script` entity via `TagConfig.scriptId`, change-trigger
|
||||
via mux + timer-trigger by reviving the dormant `TimerTriggerScheduler` inside the driver,
|
||||
deploy gates (scriptId existence + Tarjan cycle check via the dormant `DependencyGraph`;
|
||||
compile deliberately NOT hard-gated, parity with VirtualTags), and VT-parity error
|
||||
semantics (Bad + last-known value + script-log). Calc-of-calc chains work for free because
|
||||
published calc values re-enter the mux — which is exactly why the cycle gate is mandatory
|
||||
(an undetected cycle is a live oscillation loop).
|
||||
|
||||
## Build plan (Approach B — phased)
|
||||
|
||||
*(Re-sliced after review: the original Batch 1 was unbuildable — dropping `Tag.EquipmentId`/
|
||||
`FolderPath` and `Equipment.DriverInstanceId` breaks the composer, artifact, runtime binding,
|
||||
`DraftValidator`, `UnsTreeService`, and `EquipmentPage` at compile time. Batch 1 now owns
|
||||
that rewiring explicitly; the address space is intentionally dark until Batch 4.)*
|
||||
|
||||
| Batch | Content | Verification |
|
||||
|---|---|---|
|
||||
| **1 — Schema** | Greenfield EF model + migration + dev-rig re-seed | build/test; migration applies; seed loads |
|
||||
| **2 — Raw UI** | `/raw` project tree, right-click popups, driver config/test, tag manual/CSV/browse, `Calculation` driver | Live `/run` on docker-dev `:9200`: author a driver + tags in-tree, Test-connect green, author a calc tag |
|
||||
| **3 — UNS rework** | Equipment reference-only, raw-tag multi-select picker | Live `/run`: reference raw tags into an equipment; display-name override shows |
|
||||
| **4 — Address space** | Dual namespace, UNS→Raw delegation, raw-only binding | Live `/run` + Client.CLI: browse both namespaces, read/write through both, HistoryRead/alarm at raw visible via UNS |
|
||||
| **1 — Schema + identity rewiring** | Greenfield EF model + migration + dev-rig re-seed; **RawPath identity contract** through `TagConfigIntent`-successor, all 8 drivers' resolvers (retire blob-fallback parsers), composer/artifact/`DriverHostActor`/`DraftValidator`/`UnsTreeService` rewired to the new shape; endpoint→`DeviceConfig` move incl. the 4 embedded-`Devices[]` drivers. Address space composes hierarchy-only/empty (**dark until Batch 4**) | build/test green incl. golden-corpus rewrite; migration applies; seed loads |
|
||||
| **2 — Raw UI** | `/raw` project tree (lazy loading + new context-menu component), driver config/test modals, tag manual/CSV/browse (**depends on the universal `DiscoveryDriverBrowser` having landed pre-v3**; Batch 2 re-targets its commit to raw Tag rows + merged Driver+Device config), `Calculation` driver | Live `/run` on docker-dev `:9200`: author a driver + tags in-tree, Test-connect green, browse-commit raw tags from a discovery-capable driver, author a calc tag. **This gate proves authoring + probe only — no values are observable until Batch 4** |
|
||||
| **3 — UNS rework** | Equipment reference-only, raw-tag multi-select picker, effective-name uniqueness, `{{equip}}` reference-relative resolution | Live `/run`: reference raw tags into an equipment; display-name override shows; deploy-gate rejects a reference collision |
|
||||
| **4 — Address space** | Dual namespace, UNS fan-out registration, raw-only binding, alarm/event dual-notifier, historian dual-registration, sink-interface namespace discriminator + `DeferredAddressSpaceSink` forwarding (+ reflection-test extension) | Live `/run` + Client.CLI: browse both namespaces, read/write through both, HistoryRead + native alarm at raw visible via UNS, `{{equip}}` script resolves |
|
||||
|
||||
Batches are sequential; batch 4 landing = **v3.0**.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit** (xUnit + Shouldly): schema integrity rules (block delete-while-referenced,
|
||||
non-empty folder delete), CSV round-trip, `DriverType` canonicalization, UNS-reference
|
||||
resolution, `Calculation` driver value production.
|
||||
non-empty folder delete, sibling-name uniqueness, effective-name collision), CSV
|
||||
round-trip, `DriverType` canonicalization, RawPath resolution per driver, UNS-reference
|
||||
resolution, `{{equip}}` reference-relative substitution + unresolved-ref rejection,
|
||||
calc-tag cycle detection, `Calculation` driver value production.
|
||||
- **Integration** (`*.IntegrationTests`): address-space builder emits both namespaces with
|
||||
correct NodeIds; UNS delegation resolves to the raw value source.
|
||||
correct NodeIds; UNS node receives the raw value via fan-out; write via UNS NodeId reaches
|
||||
the driver; native alarm event visible at both notifiers.
|
||||
- **Live verification per batch — non-negotiable.** This project has a repeated history of
|
||||
prod-inertness bugs that unit tests + review missed (e.g. the `DeferredAddressSpaceSink`
|
||||
forwarding trap). Every batch gets a docker-dev `/run` gate before it is "done."
|
||||
@@ -207,6 +455,22 @@ Batches are sequential; batch 4 landing = **v3.0**.
|
||||
|
||||
- **Dangling reference guard** — raw tag delete blocked while referenced; UI names the
|
||||
equipment.
|
||||
- **Rename cascade (the price of path-shaped identity)** — renaming a raw tag, or any
|
||||
ancestor folder/driver/device/group, changes the RawPath and therefore: the `ns=Raw` NodeId
|
||||
(client subscriptions to the old NodeId break), the default historian tagname (history
|
||||
continues under a **new** tag unless a `historianTagname` override pins it), `ctx.GetTag`
|
||||
literals in scripts (deploy-gate error for `{{equip}}` refs; silent `BadNodeIdUnknown` for
|
||||
absolute RawPath literals), and external raw bindings (ScadaBridge). Mitigations: the UI
|
||||
**warns on rename** when the tag is historized, UNS-referenced, or matched by a script
|
||||
literal; historian continuity is achieved by setting `historianTagname` before renaming.
|
||||
This is Kepware-equivalent behavior, accepted deliberately.
|
||||
*Script-literal detection for the rename warning (decided):* a **substring scan of
|
||||
`Script` bodies for the old RawPath** at rename time, service-level. The compose-time
|
||||
dependency-ref index only exists per deploy (stale at authoring time), and a warning
|
||||
tolerates false positives — the scan is cheap, always current, and needs no new
|
||||
bookkeeping. `{{equip}}`-relative refs don't need scanning at all: they resolve through
|
||||
`UnsTagReference` rows, so a rename can't silently strand them (the deploy gate catches
|
||||
any resulting breakage).
|
||||
- **Datatype change on a referenced raw tag** — propagates to UNS (raw is truth); if it
|
||||
breaks a UNS consumer, that surfaces at browse/subscribe time (documented, not silently
|
||||
patched).
|
||||
@@ -215,18 +479,72 @@ Batches are sequential; batch 4 landing = **v3.0**.
|
||||
- **Capability-gated browse** — non-browsable drivers show a disabled action + tooltip, never
|
||||
a broken picker.
|
||||
- **Cross-cluster reference** — structurally impossible (the UNS picker is cluster-scoped).
|
||||
- **UNS effective-name collision** — rejected at authoring; rename-induced collisions caught
|
||||
by the `DraftValidator` deploy gate with a clear error naming both sources.
|
||||
|
||||
## Out of scope for v3.0 (follow-ups)
|
||||
|
||||
- Pattern/CSV-based auto-linking of raw → UNS references.
|
||||
- Live browse for the address-entry protocols (Modbus/S7/AB-CIP/AB-Legacy/TwinCAT/FOCAS) via
|
||||
per-protocol template catalogs.
|
||||
- Address-entry assistance for the genuinely non-browsable flat-address drivers
|
||||
(Modbus/Modbus-RTU/S7/AB-Legacy) via per-protocol template catalogs. *(Narrowed from the
|
||||
original follow-up: AbCip/TwinCAT/FOCAS gain live browse pre-v3 via the universal
|
||||
`DiscoveryDriverBrowser` — see `docs/plans/2026-07-15-universal-discovery-browser-design.md`.)*
|
||||
- Optional Raw-namespace hiding toggle (security-sensitive deployments exposing only UNS).
|
||||
|
||||
## Appendix — CSV column dictionaries (decided 2026-07-15)
|
||||
|
||||
Common columns (every driver): `Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent,
|
||||
PollGroup, IsHistorized, HistorianTagname, IsArray, ArrayLength, Alarm.AlarmType,
|
||||
Alarm.Severity, Alarm.HistorizeToAveva, TagConfigJson`. The `Alarm.*` dotted columns are the
|
||||
flattened native-alarm object (absent columns = no alarm); `TagConfigJson` is the fallback
|
||||
for any key not covered by a typed column (every editor already preserves unknown keys, so
|
||||
it round-trips safely).
|
||||
|
||||
Per-driver typed columns (from the existing `<Driver>TagConfigModel` scalar surfaces, minus
|
||||
the keys v3 normalizes away — no `deviceHostAddress`, no PascalCase `FullName`):
|
||||
|
||||
| Driver | Typed columns |
|
||||
|---|---|
|
||||
| Modbus | `Region, Address, ModbusDataType, ByteOrder, BitIndex, StringLength, Writable` |
|
||||
| S7 | `Address, S7DataType, StringLength, Writable` |
|
||||
| AbCip | `TagPath, AbCipDataType, Writable` |
|
||||
| AbLegacy | `Address, AbLegacyDataType, Writable` |
|
||||
| TwinCAT | `SymbolPath, TwinCATDataType, Writable` |
|
||||
| FOCAS | `Address, FocasDataType` *(no `writable` key exists for FOCAS)* |
|
||||
| OpcUaClient | `NodeId` |
|
||||
| Galaxy | `AttributeRef` |
|
||||
| Calculation | `ScriptId, ChangeTriggered, TimerIntervalMs` |
|
||||
|
||||
Enum-typed columns accept the enum member names (case-insensitive), matching the editors'
|
||||
string-name JSON convention.
|
||||
|
||||
## Open items
|
||||
|
||||
**None.** All seven review-era open items were resolved on 2026-07-15:
|
||||
|
||||
| Item | Resolution |
|
||||
|---|---|
|
||||
| Historian tagname constraints vs RawPath | Live-probed vs the real AVEVA historian: **255-char limit**, path charset fully accepted, byte-exact round-trip; >255 ⇒ `DraftValidator` error prompting a `historianTagname` override (identity-contract section) |
|
||||
| `NamespaceKind.Simulated` disposition | Verified vestigial (reserved enum + UI placeholder only); retired with the `Namespace` entity (data-model section) |
|
||||
| SDK spike: multi-notifier event reporting | Native `AddNotifier` multi-root wiring confirmed from SDK source; duplicate-report fallback rejected (address-space section) |
|
||||
| `Calculation` driver mini-design | Written: `docs/plans/2026-07-15-calculation-driver-mini-design.md` |
|
||||
| Per-driver CSV column dictionaries | Decided (appendix above) |
|
||||
| ScadaBridge cutover coordination | Sized (2 DB columns, `ns=2` index hard-coding) + planned (cross-repo paragraph) |
|
||||
| Rename-warning scope | Authoring-time substring scan over `Script` bodies (edge-cases section) |
|
||||
|
||||
Nothing gates any batch. The design is ready for implementation planning (tasks #7–#11).
|
||||
|
||||
## Implementation tasks
|
||||
|
||||
- #7 — Raw/UNS v3 schema
|
||||
- #8 — Global Raw project-tree AdminUI (`/raw`)
|
||||
- #9 — UNS Equipment reference-only rework
|
||||
- #10 — Dual-namespace address space + raw-only runtime binding
|
||||
**Implementation plans written 2026-07-15** (master orchestration + one plan per batch,
|
||||
sized for parallel agent execution with per-wave file ownership and live gates):
|
||||
`2026-07-15-v3-implementation-plan.md` →
|
||||
`…-v3-batch1-schema-identity-plan.md` / `…-v3-batch2-raw-ui-calculation-plan.md` /
|
||||
`…-v3-batch3-uns-rework-plan.md` / `…-v3-batch4-address-space-plan.md`.
|
||||
|
||||
- #7 — Raw/UNS v3 schema **+ RawPath identity rewiring** (re-scoped per Batch 1)
|
||||
- #8 — Global Raw project-tree AdminUI (`/raw`) incl. context-menu + lazy-tree infra
|
||||
- #9 — UNS Equipment reference-only rework + `{{equip}}` reference-relative resolution
|
||||
- #10 — Dual-namespace address space + raw-only runtime binding + alarm/event/historian dual-surface
|
||||
- #11 — `Calculation` pseudo-driver for signal-level calc tags
|
||||
(mini-design: `docs/plans/2026-07-15-calculation-driver-mini-design.md`)
|
||||
|
||||
@@ -62,7 +62,7 @@ All `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors`, mat
|
||||
|---|---|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/` | Options DTOs, `SqlProvider` enum, tag-model records, `SqlEquipmentTagParser`. **Zero transport deps** (refs only `Core.Abstractions`) so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/` | `SqlDriver` (runtime capabilities), `ISqlDialect` + `SqlServerDialect`, `SqlPollReader`, factory extensions. **Owns the `Microsoft.Data.SqlClient` package ref.** |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is a deliberate deviation from `OpcUaClient.Browser` (which refs only its Contracts and owns the OPC UA client packages itself): the browser still opens its own transient connection. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is a deliberate deviation from `OpcUaClient.Browser` (which refs only its Contracts and owns the OPC UA client packages itself): the browser still opens its own transient connection. The resulting **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. Recorded here deliberately so nobody "fixes" the deviation later without this context. |
|
||||
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/` | Unit suite (SQLite fixture, §9). |
|
||||
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/` | Env-gated integration suite (central SQL Server, §9). |
|
||||
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/` | Browser unit tests (SQLite `PRAGMA` dialect). |
|
||||
@@ -257,6 +257,16 @@ Three models; **(a) and (b) ship in P1**, (c) is P3.
|
||||
per poll; index rows by key, slice back. Values in the `IN` list are **bound parameters**, one per
|
||||
key — never interpolated.
|
||||
|
||||
**Query contract — one row per key (v1).** The `WHERE <keyColumn> IN (…)` poll assumes the authored
|
||||
source yields **exactly one row per key**. Point the tag at a current-values table, or — for
|
||||
append-only staging/history tables that hold many rows per key — at a **view or query the operator
|
||||
writes** that reduces to latest-per-key (`GROUP BY key` + `MAX(timestamp)`, or
|
||||
`ROW_NUMBER() OVER (PARTITION BY <keyColumn> ORDER BY <timestampColumn> DESC) = 1`); that
|
||||
operator-authored latest-per-key view is the **supported workaround** for append-only sources. If a
|
||||
poll nonetheless returns multiple rows for a key, the driver deterministically takes the **last**
|
||||
occurrence in reader order and emits a **rate-limited warning** log naming the offending key — the
|
||||
value stays usable, but the config is telling you the source violates the contract.
|
||||
|
||||
**(b) Wide row** *(cheap).* One "latest status" row, many columns → many tags. A tag names a `table`,
|
||||
a `columnName`, and a `rowSelector` (`{whereColumn, whereValue}` or `{topByTimestamp: <col>}`).
|
||||
**GroupKey = `(table, rowSelector)`.** All wide-row tags on the same row fold into one
|
||||
@@ -297,7 +307,9 @@ the address-space layer maps to OPC UA built-ins.
|
||||
**override** the inferred type explicitly (`"type": "Int32"`) — the operator sometimes knows better
|
||||
than column metadata (e.g. a `varchar` column holding a number); the driver then coerces the read
|
||||
cell to the declared type. `NULL` → `DataValueSnapshot { Value = null }` with `Good`/`Uncertain` per
|
||||
a `nullIsBad` option (default: treat as `Uncertain`, not `Bad`).
|
||||
a `nullIsBad` option (default: treat as `Uncertain`, not `Bad`). A key **entirely absent** from the
|
||||
result set (row deleted) is distinct from a NULL cell: that tag's snapshot goes **`BadNoData`** —
|
||||
`nullIsBad` governs only a present row whose value cell is NULL.
|
||||
|
||||
**Source timestamp:** if a tag declares `timestampColumn`, that column's value becomes
|
||||
`DataValueSnapshot.SourceTimestampUtc`; otherwise the poll-read wall-clock is used for both source and
|
||||
@@ -376,7 +388,7 @@ vs wide-row) + fills the key/row selector. `SecurityClass = ViewOnly` in read-on
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `Driver.Sql.Browser/SqlDriverBrowser.cs` | `IDriverBrowser`, `DriverType => "Sql"`; `OpenAsync(configJson, ct)` deserializes options, resolves the connection string from the form JSON (same trust boundary as `TestDriverConnect` + the OpcUaClient browser — transient, never cached), opens a `DbConnection`, returns a `SqlBrowseSession`. |
|
||||
| `Driver.Sql.Browser/SqlDriverBrowser.cs` | `IDriverBrowser`, `DriverType => "Sql"`; `OpenAsync(configJson, ct)` deserializes options and resolves the connection string from the `connectionStringRef` in the form JSON via a **direct env-var read** (`Sql__ConnectionStrings__<ref>`) **in the AdminUI process** — the same resolution the driver performs at Initialize (§8.2). If the ref doesn't resolve, the open **fails with an actionable message naming the exact missing env var** (e.g. `Connection string ref 'MesStaging' not found — set env var Sql__ConnectionStrings__MesStaging on this admin node`). The picker form **additionally accepts a pasted literal connection string** for ad-hoc browse — used for that browse session only, **never persisted** into the driver config (same trust boundary as `TestDriverConnect` + the OpcUaClient browser — transient, never cached). Opens a `DbConnection`, returns a `SqlBrowseSession`. |
|
||||
| `Driver.Sql.Browser/SqlBrowseSession.cs` | `IBrowseSession`; `RootAsync`/`ExpandAsync`/`AttributesAsync` over the dialect catalog queries; a `SemaphoreSlim _gate` (one ADO.NET connection is not concurrent). Per-call work is bounded by the AdminUI's existing 20 s per-call timeout. |
|
||||
| AdminUI DI (`EndpointRouteBuilderExtensions.cs`, beside `:49-50`) | `services.AddSingleton<IDriverBrowser, SqlDriverBrowser>();` |
|
||||
| `AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` | `["Sql"] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor)` (§6). |
|
||||
@@ -386,6 +398,15 @@ The browser reuses the AdminUI `BrowseSessionRegistry` + reaper + 2-min idle TTL
|
||||
unchanged. **The universal browser DI line is NOT added for `Sql`** — its `CanBrowse` is false anyway,
|
||||
and the bespoke registration wins.
|
||||
|
||||
**Deployment requirement — env parity across split nodes.** Because the browser resolves
|
||||
`connectionStringRef` in the **AdminUI process**, on split-role deployments the **admin nodes must
|
||||
carry the same `Sql__ConnectionStrings__<ref>` env vars as the driver nodes** for every ref the
|
||||
operator wants browseable (the same split-role lesson as driver probes on admin nodes). A ref present
|
||||
only on driver nodes still *polls* fine at runtime but cannot be *browsed*; when that happens the
|
||||
browser-open failure names the exact missing env var so the operator knows precisely what to add to
|
||||
the admin node's environment — or falls back to pasting a literal connection string into the picker
|
||||
for a one-off session-only browse.
|
||||
|
||||
---
|
||||
|
||||
## 5. TagConfig + driver-config JSON
|
||||
@@ -398,7 +419,7 @@ and the bespoke registration wins.
|
||||
// Connection string is resolved at Initialize from an env/secret ref — NOT stored here.
|
||||
"connectionStringRef": "MesStaging", // => env Sql__ConnectionStrings__MesStaging
|
||||
"defaultPollInterval": "00:00:05",
|
||||
"operationTimeout": "00:00:15", // per-query wall-clock deadline (→ BadTimeout)
|
||||
"operationTimeout": "00:00:15", // per-query wall-clock deadline (→ BadTimeout); MUST be > commandTimeout (§8.3)
|
||||
"commandTimeout": "00:00:10", // server-side CommandTimeout backstop (seconds granularity)
|
||||
"maxConcurrentGroups": 4, // cap on concurrent group queries (pool guard)
|
||||
"nullIsBad": false, // NULL cell → Uncertain (default) vs Bad
|
||||
@@ -436,7 +457,8 @@ and the bespoke registration wins.
|
||||
```
|
||||
|
||||
GroupKey for batching = `("dbo.TagValues", "tag_name", "num_value", "sample_ts")`; all such tags fold
|
||||
into one `… WHERE tag_name IN (@k0,@k1,…)` per poll.
|
||||
into one `… WHERE tag_name IN (@k0,@k1,…)` per poll. The `table` must yield one row per `keyValue` —
|
||||
for append-only sources, author a latest-per-key view and point `table` at it (§3.6 query contract).
|
||||
|
||||
### 5.3 Per-tag `TagConfig` — wide-row model
|
||||
|
||||
@@ -485,7 +507,9 @@ its raw `TagConfig` JSON, parsed once and cached) — same as Modbus.
|
||||
## 6. Typed editor + validator + the enum-serialization trap
|
||||
|
||||
Add `["Sql"] = typeof(…SqlTagConfigEditor)` to `TagConfigEditorMap` and a matching entry to
|
||||
`TagConfigValidator`. The editor is a thin Razor shell over a pure `SqlTagConfigModel`
|
||||
`TagConfigValidator`. The validation layer also enforces the driver-level timeout-ordering rule
|
||||
(§8.3): `operationTimeout` must be strictly greater than `commandTimeout`, rejected (or warned on)
|
||||
at authoring time. The editor is a thin Razor shell over a pure `SqlTagConfigModel`
|
||||
(`FromJson`/`ToJson`/`Validate`, preserves unknown keys) — copy the Modbus template under
|
||||
`Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`. The model exposes: `Model` (KeyValue/WideRow/
|
||||
Query), `Table`, `KeyColumn`/`KeyValue`/`ValueColumn`/`TimestampColumn` (key-value), `ColumnName` +
|
||||
@@ -588,9 +612,14 @@ comment) and the env-overridable ConfigDb connection string:
|
||||
- **Never log the resolved connection string.** Log only provider + server host + database name.
|
||||
- Prefer integrated/managed auth where the estate supports it (`Integrated Security=true` / Azure AD /
|
||||
Kerberos) so no password transits config at all.
|
||||
- The `.Browser` path receives the secret in form JSON over the authenticated Blazor circuit (same
|
||||
boundary as every other driver browser + `TestDriverConnect`); it builds one transient connection
|
||||
and releases it — **no `_lastConfigJson` cached field anywhere.**
|
||||
- The `.Browser` path resolves `connectionStringRef` via the **same direct env read, in the AdminUI
|
||||
process** (§4.4) — which is why split-role deployments need **env parity**: admin nodes carry the
|
||||
same `Sql__ConnectionStrings__<ref>` vars as driver nodes for any browseable ref, and an unresolved
|
||||
ref fails the browse open with a message naming the exact missing env var. A pasted ad-hoc literal
|
||||
connection string travels only over the authenticated Blazor circuit (same boundary as every other
|
||||
driver browser + `TestDriverConnect`), is used for that browse session only, and is never persisted
|
||||
into the driver config or logged. Either way the browser builds one transient connection and
|
||||
releases it — **no `_lastConfigJson` cached field anywhere.**
|
||||
|
||||
### 8.3 Timeout / frozen-peer (high, risk #3)
|
||||
|
||||
@@ -599,6 +628,14 @@ Per §3.2: `CommandTimeout` (server-side backstop) **and** `ExecuteReaderAsync(l
|
||||
R2-01 S7 blackhole finding. A frozen DB surfaces `BadTimeout` per group + degrades the driver + backs
|
||||
off the poll loop; it must never wedge a poll thread. Asserted by the blackhole live-gate (§9).
|
||||
|
||||
**Timeout-ordering rule (authoring validation).** `operationTimeout` **must be strictly greater than**
|
||||
`commandTimeout` — the defaults (15 s > 10 s) encode this deliberately. The driver-page /
|
||||
`TagConfigValidator`-level check **rejects** (or at minimum warns loudly on)
|
||||
`operationTimeout <= commandTimeout`: inverting the order means the client-side abort always fires
|
||||
first and **masks the server-side backstop** — every server-side timeout would surface as a generic
|
||||
client cancellation instead of the `CommandTimeout` failure it actually is, hiding the diagnostic
|
||||
that distinguishes "slow query/lock wait" from "driver gave up".
|
||||
|
||||
### 8.4 Connection pooling / exhaustion (medium)
|
||||
|
||||
Open-use-dispose per poll (`await using` on both `DbConnection` and `DbDataReader` — never leak a
|
||||
|
||||
@@ -127,19 +127,75 @@ narrow the driver config or use manual entry" rather than OOM on a 100 k-node ba
|
||||
capture on a short interval until the node set is non-empty and stable across two passes,
|
||||
bounded by the open-timeout — the same contract `DriverInstanceActor` honours at deploy.
|
||||
5. `await driver.ShutdownAsync(...)` in a `finally` — the tree is fully captured; hold nothing
|
||||
live. (No lazy expand ⇒ no need to keep the connection.)
|
||||
live. (No lazy expand ⇒ no need to keep the connection.) **The shutdown itself is bounded**
|
||||
(the R2-01 per-op-deadline rule, program doc §3.1 — no unbounded waits anywhere): it runs
|
||||
under its **own 10 s linked CTS**, because a driver wedged during discovery may be equally
|
||||
wedged in shutdown, and cleanup must not extend the open beyond open-timeout + that bound.
|
||||
On shutdown timeout: log a warning, **abandon the instance** (drop the reference — never
|
||||
block on it or rethrow), and still return / fail the open on the **discovery outcome** —
|
||||
a hung shutdown neither fails a successful capture nor masks the real discovery error.
|
||||
6. return `new CapturedTreeBrowseSession(capture.Root)`.
|
||||
|
||||
`CanBrowse(driverType, configJson)` = `TryCreate` (cheap, no connect) succeeds **and** the
|
||||
instance is `ITagDiscovery { SupportsOnlineDiscovery: true }`. Used by the AdminUI to decide
|
||||
whether to render the **Browse** button vs. manual entry, before any connect.
|
||||
#### Capture concurrency — coalescing + global cap
|
||||
|
||||
Every `OpenAsync` is heavy: it constructs, connects, and fully enumerates a **real driver
|
||||
against the live device**. Repeated Browse clicks (or two operators picking against the same
|
||||
controller) must not stack parallel symbol walks on one PLC. Two controls, both inside
|
||||
`DiscoveryDriverBrowser`:
|
||||
|
||||
- **In-flight coalescing**, keyed on `(driverType, hash of configJson)` (e.g. SHA-256 of the
|
||||
caller-supplied JSON; `PatchForBrowse` is a pure function of `driverType`, so hashing the
|
||||
pre-patch config is equivalent per key). A second open for the same key **awaits the same
|
||||
capture task** rather than starting another walk; each awaiting caller then receives its
|
||||
**own** `CapturedTreeBrowseSession` (own token, own TTL in the `BrowseSessionRegistry`)
|
||||
over the **shared, immutable captured tree**. A failed capture fails every coalesced
|
||||
awaiter with the same error. The coalescing map holds **in-flight captures only** — no
|
||||
result caching: the entry is removed when the capture completes (success or failure), so
|
||||
the next open for that key (including a Refresh, §4.3) triggers a fresh capture.
|
||||
- **A global cap on simultaneous captures** — `MaxConcurrentCaptures = 4` (a const, like
|
||||
`BrowserSessionService.PerCallTimeout`). Excess opens **queue** (semaphore, FIFO) and still
|
||||
honour the caller's cancellation token while queued. Coalesced awaiters don't consume a
|
||||
slot — only distinct in-flight captures count against the cap.
|
||||
|
||||
`CanBrowse(driverType, configJson)` = `TryCreate` (cheap, no connect) yields a non-null
|
||||
instance **and** that instance is `ITagDiscovery { SupportsOnlineDiscovery: true }`. Used by
|
||||
the AdminUI to decide whether to render the **Browse** button vs. manual entry, before any
|
||||
connect. Two hardening rules:
|
||||
|
||||
- **`TryCreate` can throw, not just return null.** Factories parse `configJson` *inside*
|
||||
`TryCreate`, so malformed/half-typed JSON (exactly what a picker holds mid-authoring) can
|
||||
surface as an exception. `CanBrowse` must catch **any** exception → `false` — it is a UI
|
||||
affordance gate and must never throw into the page.
|
||||
- **Defensive teardown of the throwaway instance.** Whatever the outcome (not discovery-capable,
|
||||
flag false, or an exception after construction), `CanBrowse` best-effort
|
||||
`ShutdownAsync`s/disposes any instance it *did* create — bounded, swallow-all — so probing
|
||||
never leaks driver instances.
|
||||
|
||||
**Invariant the throwaway-instance pattern depends on:** driver constructors are
|
||||
**connection-free** today — every driver in the tree connects in `InitializeAsync`, never in
|
||||
its ctor. `CanBrowse` (construct → inspect → discard, potentially on every picker render) and
|
||||
the capture path's construct-then-patch flow both rely on this. A future driver that opens a
|
||||
device connection in its constructor would silently turn `CanBrowse` into a connect storm —
|
||||
treat "no I/O in driver ctors" as a driver-authoring rule this browser now depends on (worth a
|
||||
line in the program doc's cross-cutting rules when P1 lands).
|
||||
|
||||
### 4.3 `CapturedTreeBrowseSession : IBrowseSession`
|
||||
|
||||
Serves entirely from the in-memory captured tree (the shape the MTConnect research report
|
||||
proposed for its cached-`/probe` session — now subsumed by this generic component): `RootAsync` = top-level nodes; `ExpandAsync(nodeId)` = the captured children of that
|
||||
node; `AttributesAsync(nodeId)` = the leaf's `AttributeInfo` (+ any captured properties).
|
||||
`Token`/`LastUsedUtc` per the interface; `DisposeAsync` drops the tree. No I/O after open.
|
||||
`Token`/`LastUsedUtc` per the interface; `DisposeAsync` drops the **session's reference** to
|
||||
the tree (the tree is immutable and may be shared by coalesced sessions, §4.2 — disposing one
|
||||
session never affects another). No I/O after open.
|
||||
|
||||
**Point-in-time snapshot + Refresh.** The captured tree is a snapshot taken at open time and
|
||||
never updates for the session's whole TTL — a tag added on the controller after open does not
|
||||
appear. The picker therefore exposes a **Refresh** action = close the session + re-open it
|
||||
(`CloseAsync` then `OpenAsync` with the same driverType/configJson): a fresh capture that flows
|
||||
through the same coalescing/cap path as any open. Because the coalescing map holds in-flight
|
||||
captures only (no result caching), Refresh always yields a new capture — or joins one already
|
||||
in flight, which is equally fresh. Cheap to build (the picker already has both calls); the UI
|
||||
should label browse results as "snapshot taken at open/refresh time".
|
||||
|
||||
## 5. The online-discovery capability gate
|
||||
|
||||
@@ -231,9 +287,10 @@ turns the FixedTree on; the settle waits for its cache to fill.)
|
||||
1. **Eager, not lazy.** `DiscoverAsync` runs the whole enumeration at open. For huge trees
|
||||
(ControlLogix 10 k+ tags, OPC UA 100 k nodes) the node-cap truncates and the UX is worse than
|
||||
a bespoke per-click `ExpandAsync`. Graduate those to Tier 2.
|
||||
2. **Runs the real driver in-process** (construct + connect + one-shot discover + shutdown) —
|
||||
heavier than the lightweight ad-hoc bespoke session, and requires the driver assemblies in the
|
||||
host (fused Host: yes).
|
||||
2. **Runs the real driver in-process** (construct + connect + one-shot discover + bounded
|
||||
shutdown) — heavier than the lightweight ad-hoc bespoke session, and requires the driver
|
||||
assemblies in the host (fused Host: yes). The §4.2 coalescing + `MaxConcurrentCaptures` cap
|
||||
bound the aggregate load, but each individual capture is still a full device walk.
|
||||
3. **No incrementally-fetched attribute panel** beyond what `DriverAttributeInfo` +
|
||||
`AddProperty` carry (fine for the picker's needs; Galaxy's richer two-stage pick stays bespoke).
|
||||
|
||||
@@ -243,10 +300,29 @@ turns the FixedTree on; the settle waits for its cache to fill.)
|
||||
known Folder/Variable/AddProperty/alarm graph; assert the `BrowseNode` tree, leaf `NodeId ==
|
||||
FullName`, `AttributeInfo` mapping, node-cap truncation, and the no-op alarm sink.
|
||||
- **Unit — `DiscoveryDriverBrowser`:** fake `IDriverFactory` returning a fake driver; assert
|
||||
Initialize→Discover→Shutdown ordering, open-timeout, `PatchForBrowse` merge, `CanBrowse` gate
|
||||
Initialize→Discover→Shutdown ordering (shutdown always runs, even on discovery failure),
|
||||
open-timeout, `PatchForBrowse` merge, `CanBrowse` gate
|
||||
(true for `SupportsOnlineDiscovery`, false otherwise / `TryCreate`→null), and the
|
||||
`UntilStable` settle (a fake `UntilStable` driver whose tree only fills on the second
|
||||
discovery pass must yield the full tree; a never-stable one must stop at the open-timeout).
|
||||
- **Unit — capture coalescing:** two concurrent `OpenAsync` calls with the same
|
||||
(driverType, configJson) run **one** capture (fake driver counts Initialize/Discover
|
||||
invocations) and both callers receive **distinct session tokens** over the shared tree;
|
||||
disposing one session leaves the other browsable; a failed capture fails both awaiters; a
|
||||
subsequent open after completion (Refresh path) runs a **new** capture (in-flight-only map,
|
||||
no result caching).
|
||||
- **Unit — capture cap queueing:** with `MaxConcurrentCaptures = 4` and >4 concurrent opens on
|
||||
**distinct** keys (fake drivers gated on a test signal), at most 4 captures are in flight at
|
||||
once; queued opens complete after a slot frees; a queued open observes caller cancellation.
|
||||
- **Unit — `CanBrowse` hardening:** a fake factory whose `TryCreate` **throws** on malformed
|
||||
configJson yields `CanBrowse == false` (no exception escapes); when `TryCreate` returns a
|
||||
non-discovery (or flag-false) instance, `CanBrowse` is false **and** the created instance
|
||||
was shut down/disposed.
|
||||
- **Unit — bounded shutdown:** a fake driver whose `ShutdownAsync` never completes — the open
|
||||
still returns within open-timeout + the 10 s shutdown bound, on the **discovery** outcome,
|
||||
for both legs: successful capture ⇒ a usable session is returned; failed discovery ⇒ the
|
||||
discovery error (not a shutdown timeout) surfaces. The wedged instance is abandoned, not
|
||||
awaited.
|
||||
- **Unit — `BrowserSessionService` resolution:** bespoke-first, universal-fallback, and
|
||||
manual-entry-when-neither; confirm no `ToDictionary` collision.
|
||||
- **Integration (fixture-gated):** point the universal browser at the existing driver fixtures on
|
||||
@@ -261,9 +337,12 @@ turns the FixedTree on; the settle waits for its cache to fill.)
|
||||
- **P1 — universal browser end-to-end:** `CapturingAddressSpaceBuilder` +
|
||||
`CapturedTreeBrowseSession` + `DiscoveryDriverBrowser` + `IUniversalDriverBrowser` +
|
||||
`BrowserSessionService` fallback + `ITagDiscovery.SupportsOnlineDiscovery` default member +
|
||||
the AbCip/TwinCAT/FOCAS patches + the `UntilStable` settle loop (§4.2) + DI line + AdminUI
|
||||
`CanBrowse`→Browse-button gate. Set `SupportsOnlineDiscovery=true` on
|
||||
OpcUaClient(n/a—bespoke), AbCip, TwinCAT, FOCAS. **~3–5 days.**
|
||||
the AbCip/TwinCAT/FOCAS patches + the `UntilStable` settle loop (§4.2) + capture coalescing
|
||||
and the `MaxConcurrentCaptures` cap + the bounded (10 s) shutdown + hardened `CanBrowse`
|
||||
(catch-throwing-`TryCreate`, defensive teardown) + DI line + AdminUI
|
||||
`CanBrowse`→Browse-button gate + the picker **Refresh** action (§4.3). Set
|
||||
`SupportsOnlineDiscovery=true` on OpcUaClient(n/a—bespoke), AbCip, TwinCAT, FOCAS.
|
||||
**~3–5 days.**
|
||||
- **P2 — light up new drivers for free:** as MTConnect/BACnet land, each sets
|
||||
`SupportsOnlineDiscovery=true` (+ patch if config-gated) and gets a working picker with no
|
||||
browser code. (MQTT/Sparkplug and SQL poll ship bespoke browsers instead — program doc §4.)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# v3 Batch 1 — greenfield schema + RawPath identity rewiring
|
||||
|
||||
**Branch:** `v3/batch1-schema-identity`
|
||||
**Design authority:** `2026-07-15-raw-uns-two-subtree-v3-design.md` — §"v3 identity contract (RawPath)", §"Data model", §"Build plan" row 1.
|
||||
**Master plan:** `2026-07-15-v3-implementation-plan.md` (conventions + gotchas are binding).
|
||||
**End state:** the config schema and every identity seam speak RawPath; the solution builds and all tests pass; the address space composes hierarchy-only/empty (**deliberately dark until Batch 4**); docker-dev re-seeds and boots.
|
||||
|
||||
## Scope guard
|
||||
|
||||
- NO AdminUI feature work (Batch 2). AdminUI changes here are limited to *keeping it
|
||||
compiling* against the new entity shapes (pages that authored the old shapes may be
|
||||
temporarily stubbed with an "unavailable until Batch 2" banner — record each stub in the
|
||||
PR description; Batch 2 owns their replacement).
|
||||
- NO OPC UA node materialization changes beyond making the composer emit an empty/
|
||||
hierarchy-only composition (Batch 4 owns the dual namespace).
|
||||
- Greenfield means greenfield: no data-migration code, no back-compat shims for old
|
||||
`TagConfig` shapes, no blob-fallback retention "just in case."
|
||||
|
||||
## The RawPath contract (what every package codes against)
|
||||
|
||||
`RawPath = <Folder/…>/<DriverName>/<DeviceName>/<TagGroup/…>/<TagName>` — cluster-scoped,
|
||||
slash-separated, built ONLY by the new `RawPaths` helper (never string-concatenated ad
|
||||
hoc). Segments forbid `/` and leading/trailing whitespace (validated at authoring).
|
||||
Case-sensitive ordinal comparison everywhere. It keys: driver wire-refs, the
|
||||
`DriverHostActor` fan-out/write maps, historian default tagname + mux refs, native-alarm
|
||||
`ConditionId`, `ctx.GetTag` paths, and (Batch 4) `ns=Raw` NodeIds.
|
||||
|
||||
## Work packages
|
||||
|
||||
### Wave A — foundations (2 agents, parallel, disjoint files)
|
||||
|
||||
**B1-WP1 — Greenfield EF model + `V3Initial` migration + seeds**
|
||||
|
||||
Files (all under `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/` unless noted):
|
||||
- `Entities/`: new `RawFolder.cs`, `TagGroup.cs`, `UnsTagReference.cs`; reshape
|
||||
`DriverInstance.cs` (add nullable `RawFolderId`; **drop `NamespaceId`**), `Device.cs`
|
||||
(add `DeviceConfig` JSON; now universal — every driver ≥1 device), `Tag.cs`
|
||||
(required `DeviceId` FK, nullable `TagGroupId`; **drop `EquipmentId` + `FolderPath`**;
|
||||
**keep `WriteIdempotent`** — dropping it regresses the R2 resilience fix),
|
||||
`Equipment.cs` (**drop the `DriverInstanceId`/`DeviceId` binding**). Delete
|
||||
`Namespace.cs`, `EquipmentImportBatch.cs` (both classes), and
|
||||
`Enums/NamespaceKind.cs`; delete `NamespaceKindCompatibility` + `AllowedNamespaceKinds`
|
||||
from `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs`.
|
||||
- `OtOpcUaConfigDbContext.cs` — new DbSets; **all** uniqueness/integrity config inline in
|
||||
`OnModelCreating` per the design's rules: sibling-name unique indexes at every raw
|
||||
level (filtered indexes for nullable `ParentRawFolderId`/`RawFolderId`/`TagGroupId`),
|
||||
`OnDelete(Restrict)` so raw-tag delete is blocked while a `UnsTagReference` points at
|
||||
it and non-empty `RawFolder`/`TagGroup` deletes are blocked.
|
||||
- **Migration strategy (decided): squash.** Delete the existing `Migrations/` folder and
|
||||
scaffold a single `V3Initial` migration. Port the hand-written stored-proc migrations'
|
||||
`Up` SQL (the `*_StoredProcedures.cs` / `*_ExtendComputeGenerationDiffWith*.cs` files)
|
||||
into `V3Initial` verbatim — audit them first: any proc referencing dropped
|
||||
columns/tables (`NamespaceId`, `Tag.EquipmentId`, `Tag.FolderPath`) must be rewritten
|
||||
to the new shape, and `ComputeGenerationDiff` must diff the new tables
|
||||
(`RawFolder`/`Device`/`TagGroup`/`UnsTagReference`). Greenfield = no production DB to
|
||||
upgrade, so squashing is safe; the fixture DBs are dropped/recreated.
|
||||
- Seeds: rewrite `docker-dev/seed/seed-clusters.sql` (+ anything in
|
||||
`docker-dev/seed/entrypoint.sh` that names tables) and every `scripts/smoke/seed-*.sql`
|
||||
to the new schema — each driver gets a folder (optional), a device row with
|
||||
`DeviceConfig` (endpoint moved out of `DriverConfig` — coordinate the JSON shape with
|
||||
B1-WP5's contracts commit), and raw tags with RawPath-consistent names.
|
||||
- Tests: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/` —
|
||||
`SchemaComplianceFixture`/`SchemaComplianceTests` updated to the new schema; new unit
|
||||
tests for every integrity rule (delete-while-referenced blocked, non-empty folder/group
|
||||
delete blocked, default-device-with-tags delete blocked, sibling-name uniqueness per
|
||||
level, `/`-in-name rejected).
|
||||
|
||||
DoD: migration applies to a fresh DB (`dotnet ef database update` via
|
||||
`DesignTimeDbContextFactory`); Configuration project + tests compile and pass (other
|
||||
projects may be red until Wave B/C — that's expected mid-batch; the *batch branch* is
|
||||
only merged green).
|
||||
|
||||
**B1-WP2 — `RawPaths` helper + `TagConfigIntent` successor + key normalization**
|
||||
|
||||
Files:
|
||||
- New `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPaths.cs` — pure static: `Build(...)`
|
||||
from segment lists, `Validate(segment)` (no `/`, no lead/trail whitespace, non-empty),
|
||||
`Combine`, `TryParent`. This is the contracts-commit centerpiece — land it first.
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs` — sheds the
|
||||
FullName-derivation role: remove `FullName`/`ExplicitFullName` (the blob-as-identity
|
||||
convention dies here); **keep** the alarm/historize/array parsing
|
||||
(`TagAlarmIntent`, `ParseAlarm`/`ParseHistorize`/`ParseArray`) — those remain real
|
||||
`TagConfig` concerns. Callers to sweep (grounded list): `DraftValidator` (Galaxy
|
||||
ExplicitFullName rule → deleted; replaced by B1-WP6 rules),
|
||||
`Core/OpcUa/EquipmentNodeWalker.cs`, `OpcUaServer/AddressSpaceComposer.cs` +
|
||||
`AddressSpaceChangeClassifier.cs`, `AdminUI/ScriptAnalysis/IScriptTagCatalog.cs`,
|
||||
`AdminUI/Uns/TagEditors/OpcUaClientTagConfigModel.cs`,
|
||||
`Runtime/Drivers/DeploymentArtifact.cs`.
|
||||
- Key normalization (greenfield, decided in the design): OpcUaClient's PascalCase
|
||||
`"FullName"` → `"nodeId"`; Galaxy's → `"attributeRef"`; the `deviceHostAddress` key
|
||||
(AbCip/AbLegacy/TwinCAT/FOCAS) is **dropped** (the tag's `DeviceId` FK owns placement).
|
||||
Update `OpcUaClientTagConfigModel.cs` (AdminUI) and the two browsers' commit paths
|
||||
(`Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs`,
|
||||
`Driver.Galaxy.Browser/GalaxyBrowseSession.cs`) to emit the new keys.
|
||||
- Tests: rewrite `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs`;
|
||||
new `RawPathsTests`; delete
|
||||
`Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs` and
|
||||
`Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs` (superseded — B1-WP6
|
||||
replaces them with RawPath-shaped corpus tests).
|
||||
|
||||
### Wave B — per-driver resolver rework (up to 9 agents, parallel, one per driver + exemplar-first)
|
||||
|
||||
**B1-WP3 — `EquipmentTagRefResolver` re-keying + blob-fallback retirement**
|
||||
|
||||
Shared seam first (the wave's contracts commit, done by the coordinator or the exemplar
|
||||
agent): `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/EquipmentTagRefResolver.cs` — the
|
||||
`_byName` branch becomes the primary and only path: each deploy hands the driver its
|
||||
authored raw tags keyed by RawPath (ref string) → `TagConfig`-derived definition. The
|
||||
`_cache.GetOrAdd(fullReference, _parseRef)` blob-parse fallback is **deleted**; a miss is
|
||||
a miss (`TryResolve` false → driver logs + Bad quality), never a parse attempt.
|
||||
|
||||
Then **exemplar: Modbus** (one agent, reviewed before fan-out so the pattern is fixed):
|
||||
- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs` —
|
||||
becomes a pure `TagConfig JSON → definition` mapper invoked at deploy-time table build
|
||||
(no more "is this ref a blob?" heuristics). Rename to `ModbusTagDefinitionFactory` (or
|
||||
similar) to make the retirement visible.
|
||||
- Driver + tests updated to resolve by RawPath.
|
||||
|
||||
Then **fan-out, one agent per driver** (disjoint project directories — fully parallel):
|
||||
S7 (`S7EquipmentTagParser.cs` — the leading-`{` blob convention dies), AbCip, AbLegacy,
|
||||
TwinCAT, FOCAS (same shape as exemplar); **OpcUaClient** (no static parser — seam is
|
||||
`Driver.OpcUaClient.Contracts/NamespaceMap.cs` + `OpcUaClientDriver.cs`; the definition's
|
||||
address now reads `TagConfig.nodeId`); **Galaxy** (seam is
|
||||
`Driver.Galaxy/Browse/GalaxyDiscoverer.cs` + `Browse/AlarmRefBuilder.cs`; address reads
|
||||
`TagConfig.attributeRef`; note `AlarmRefBuilder` feeds `ConditionId` — under v3 that is
|
||||
the RawPath, keep both directions consistent).
|
||||
|
||||
Each driver agent's DoD: driver project + its unit tests green with refs = RawPaths and
|
||||
definitions sourced from the authored-tag table; zero references to
|
||||
`TagConfigIntent.FullName` remain in the driver.
|
||||
|
||||
**B1-WP5 — endpoint → `DeviceConfig` move (runs parallel with WP3; owns different files)**
|
||||
|
||||
- Contracts: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs` reworked —
|
||||
`DeviceConfig` JSON now carries host/endpoint + per-device settings for **all** drivers.
|
||||
- The 4 multi-device drivers: remove the embedded `Devices[]` list from
|
||||
`ModbusDriverOptions.cs`(*), `S7DriverOptions.cs`(*), `AbCipDriverOptions.cs`,
|
||||
`AbLegacyDriverOptions` file, `TwinCATDriverOptions.cs`, `FocasDriverOptions` file —
|
||||
(*grounding note: Modbus and S7 options also carry `Devices` lists — treat all six
|
||||
options classes the same way: `DriverConfig` keeps protocol/channel-level settings only;
|
||||
per-device settings move to `DeviceConfig`). Single-endpoint drivers (OpcUaClient
|
||||
`EndpointUrl`, Galaxy gateway address, S7 host if single) move their endpoint into the
|
||||
auto-created default device's `DeviceConfig`.
|
||||
- Runtime spawning: `Runtime/Drivers/DriverInstanceActor.cs` + the artifact device-host
|
||||
map in `Runtime/Drivers/DeploymentArtifact.cs` — driver instantiation now merges
|
||||
`DriverConfig` + per-device `DeviceConfig` rows (this merged shape is also what Batch
|
||||
2's browse modal passes as `configJson` — get the merge helper into Commons so both
|
||||
reuse it: `DriverDeviceConfigMerger`).
|
||||
- Golden corpus: rewrite
|
||||
`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs` +
|
||||
`TagConfigCorpusParityTests.cs` to the v3 shapes (no `deviceHostAddress`, no
|
||||
PascalCase `FullName`, per-device configs separate).
|
||||
|
||||
Coordination rule: WP5 and WP3 both touch driver projects. Split ownership per file:
|
||||
WP3 owns `*EquipmentTagParser.cs`/resolver/driver read-path files; WP5 owns
|
||||
`*DriverOptions.cs`/factory/spawn-path files. Where one file carries both (a driver
|
||||
factory that parses options AND builds the tag table), WP5 waits for that driver's WP3
|
||||
agent to merge first — the coordinator sequences per-driver.
|
||||
|
||||
### Wave C — pipeline integration (2 agents, then 1 integrator)
|
||||
|
||||
**B1-WP4 — composer / artifact / actors / validator / services rewiring**
|
||||
|
||||
- `OpcUaServer/AddressSpaceComposer.cs` (+ `AddressSpaceChangeClassifier.cs`) — composes
|
||||
from the new schema; emits a hierarchy-only/empty composition (folders exist as data;
|
||||
**no variable nodes materialize** — Batch 4). Keep the alarm-intent parse (it flows to
|
||||
the artifact) but drop equipment-tag NodeId composition.
|
||||
- `Runtime/Drivers/DeploymentArtifact.cs` — flatten the new shape: per-driver authored
|
||||
raw tags keyed by RawPath (feeding the WP3 resolvers), device rows with merged config
|
||||
(WP5), historized set keyed by RawPath (effective historian tagname = override else
|
||||
RawPath).
|
||||
- `ControlPlane/AdminOperations/ConfigComposer.cs` (`SnapshotAndFlattenAsync`) — snapshot
|
||||
the new tables into the sealed blob; `RevisionHash` inputs updated.
|
||||
- `Runtime/Drivers/DriverHostActor.cs` — the map keys change meaning, not shape:
|
||||
`_nodeIdByDriverRef` / `_driverRefByNodeId` / `_alarmNodeIdByDriverRef` /
|
||||
`_driverRefByAlarmNodeId` now carry RawPath as the ref string. Rename the tuple member
|
||||
`FullName` → `RawPath` so no stale semantics survive. NodeId sets stay empty until
|
||||
Batch 4 (the maps still exist; mux forwarding still runs).
|
||||
- `Configuration/Validation/DraftValidator.cs` + `DraftSnapshot(Factory)` — new rules:
|
||||
raw-name charset (no `/`, no lead/trail whitespace) at every level; **historized-tag
|
||||
effective tagname ≤ 255 chars** (live-verified AVEVA limit) → deploy error prompting a
|
||||
`historianTagname` override; retired rules deleted (Galaxy ExplicitFullName, equipment
|
||||
NodeId collision in its old form, namespace-binding checks).
|
||||
- `AdminUI/Uns/UnsTreeService.cs` + `IUnsTreeService.cs`, `Components/Pages/Uns/*`,
|
||||
`Components/Shared/Uns/TagModal.razor` etc. — compile-only pass: the UNS tree keeps
|
||||
Area/Line/Equipment; the Tags tab and TagModal driver-binding paths are stubbed behind
|
||||
an "unavailable until v3 Batch 2/3" banner (list every stub in the PR).
|
||||
- `ControlPlane/AdminOperations/AdminOperationsActor.cs` — per-tag TagConfig validation
|
||||
loop updated (no FullName checks; alarm/historize checks retained).
|
||||
|
||||
**B1-WP6 — test-suite + harness sweep**
|
||||
|
||||
- Rewrite/replace the corpus tests deleted in WP2 with RawPath equivalents (resolution
|
||||
per driver, artifact parity: `DeploymentArtifact*ParityTests.cs`).
|
||||
- `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActor*Tests.cs`,
|
||||
`DriverInstanceActor*Tests.cs`, `VirtualTags/*Tests.cs` — re-key refs to RawPaths.
|
||||
- 2-node harness `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/` — seed shapes
|
||||
updated (`EquipmentNamespaceMaterializationTests.cs` will assert the *dark* address
|
||||
space this batch: hierarchy nodes only / no variables; mark the full assertion as
|
||||
Batch-4-pending with a skip-reason constant, don't delete it).
|
||||
- `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/` — ConfigComposer/deploy-path
|
||||
shapes.
|
||||
|
||||
## Wave schedule + parallelization
|
||||
|
||||
| Wave | Packages | Agents | Parallel-safe because |
|
||||
|---|---|---|---|
|
||||
| A | WP1 ∥ WP2 | 2 | Disjoint: Configuration project vs Commons/driver-editor files. Coordinator lands `RawPaths.cs` + reshaped-entity contracts commit first |
|
||||
| B | WP3 exemplar (Modbus) → review → WP3 fan-out (7 drivers) ∥ WP5 | up to 8 | Per-driver project directories are disjoint; WP3/WP5 per-file ownership rule above |
|
||||
| C | WP4 → WP6 | 2 sequential (WP6 can start its mechanical re-keying while WP4 finishes, in a separate worktree, rebasing before merge) | WP4 is the single integration point — one agent only |
|
||||
|
||||
All agents in worktrees; merge order A → B → C; coordinator builds+tests the batch branch
|
||||
after every merge.
|
||||
|
||||
## Batch verification gate
|
||||
|
||||
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` and `dotnet test ZB.MOM.WW.OtOpcUa.slnx` green
|
||||
(macOS-safe suites), plus the DB-backed `Configuration.Tests` against
|
||||
`10.100.0.35,14330`.
|
||||
2. Fresh-DB migration proof: point `OTOPCUA_CONFIG_CONNECTION` at a scratch DB,
|
||||
`dotnet ef database update`, then run `SchemaComplianceTests`.
|
||||
3. docker-dev: rebuild the migrator image + **both** central nodes, re-seed
|
||||
(`docker-dev/seed/`), `docker compose up -d`; both nodes reach Running; seeded drivers
|
||||
show Connected against the `10.100.0.35` fixtures (`lmxopcua-fix up modbus standard`
|
||||
etc.); AdminUI loads (stubbed pages show their banners, nothing crashes).
|
||||
4. Explicitly assert the dark address space: Client.CLI
|
||||
`browse -u opc.tcp://localhost:4840 -r -d 3` shows hierarchy/no tag variables — and
|
||||
the PR description says so, so nobody files it as a bug.
|
||||
@@ -0,0 +1,193 @@
|
||||
# v3 Batch 2 — `/raw` project-tree AdminUI + `Calculation` driver
|
||||
|
||||
**Branch:** `v3/batch2-raw-ui` (opened from master after Batch 1 merges)
|
||||
**Design authority:** v3 design §"AdminUI — the Raw project tree (`/raw`)", §"Calculated tags", the CSV appendix; `2026-07-15-calculation-driver-mini-design.md` (complete spec for B2-WP7); `2026-07-15-universal-discovery-browser-design.md` §11 (B2-WP6 only).
|
||||
**Inputs from Track 0:** T0-1 `ContextMenu`, T0-2 CSV parser, T0-3 `DriverTypeNames`, T0-4 Calculation evaluator core — merge them onto this branch first if they haven't reached master.
|
||||
**End state:** everything in the Raw tree is authorable end-to-end on docker-dev (tree, drivers, devices, tag-groups, tags via manual/CSV/browse, calc tags), Test-connect works, deploys succeed. **No live values are observable — Batch 4.**
|
||||
|
||||
## Work packages
|
||||
|
||||
### Wave A — tree + data service (2 agents)
|
||||
|
||||
**B2-WP1 — `IRawTreeService` + lazy tree data layer**
|
||||
|
||||
- New `AdminUI/Uns/IRawTreeService.cs` + `RawTreeService.cs` (peer of
|
||||
`UnsTreeService`): cluster-rooted queries returning children per level
|
||||
(Folder/Driver/Device/TagGroup/Tag), **paged/lazy** (per-device tag counts can be
|
||||
large), plus mutation methods (create/rename/delete per node type, move-into-folder)
|
||||
enforcing the integrity rules with user-readable errors (delete-blocked messages name
|
||||
the referencing equipment — query `UnsTagReference` joins).
|
||||
- Rename methods are where the **rename warning** hooks (Batch 3 adds the script-scan;
|
||||
this batch wires the seam: rename returns a warnings list the UI shows — historized /
|
||||
UNS-referenced checks are already answerable from this batch's schema).
|
||||
- Unit tests against the EF model (in-memory + the DB-backed compliance suite pattern).
|
||||
|
||||
**B2-WP2 — `/raw` page + tree component**
|
||||
|
||||
- New `Components/Pages/Raw/GlobalRaw.razor` (`/raw` route, nav entry next to `/uns`) +
|
||||
`Components/Shared/Raw/RawTree.razor` — modeled on `GlobalUns`/`UnsTree`/`UnsNode`, but
|
||||
**actually exercising the lazy plumbing** (`HasLazyChildren`/loading flags exist in
|
||||
`UnsNode.cs` and were never used; reuse the pattern or make a `RawNode` twin).
|
||||
- Context menus per node type via T0-1 `ContextMenu` (menu items exactly as the design's
|
||||
list: Folder/Driver/Device/TagGroup/Tag), incl. the "⋯" fallback affordance.
|
||||
- Modals are stubs in this wave (each menu action opens a placeholder) — Waves B/C fill
|
||||
them. This keeps WP2 mergeable early so later packages build on a rendering tree.
|
||||
- Live-check on docker-dev before merge (no bUnit — the tree, lazy expansion, and menu
|
||||
must be seen working).
|
||||
|
||||
### Wave B — driver/device/tag authoring modals (3 agents, parallel)
|
||||
|
||||
**B2-WP3 — driver + device config modals (the page-to-modal refactor)**
|
||||
|
||||
- Refactor the 8 typed driver pages
|
||||
(`Components/Pages/Clusters/Drivers/{Modbus,AbCip,AbLegacy,S7,TwinCAT,Focas,OpcUaClient,Galaxy}DriverPage.razor`)
|
||||
into embeddable form bodies: extract each page's form content into
|
||||
`Components/Shared/Drivers/Forms/<Driver>DriverForm.razor`; what stays behind is the
|
||||
`@page` route + `EditForm` + ClusterNav + DB load + navigation. New
|
||||
`DriverConfigModal.razor` hosts a form body by driver type (dispatch via T0-3
|
||||
`DriverTypeNames`). Keep the old routed pages working during v3 (they're deleted only
|
||||
when `/raw` fully replaces them — end of this batch, see WP8).
|
||||
- **Device modal (new):** with Batch 1's endpoint→`DeviceConfig` move, the multi-device
|
||||
drivers' embedded device collection editors (`CollectionEditor.razor` usage inside the
|
||||
pages) become a per-Device config modal editing `DeviceConfig` JSON via typed
|
||||
per-driver device forms. Single-endpoint drivers' default device gets the same modal
|
||||
(endpoint field lives here now).
|
||||
- Test-connect inside the modal reuses `DriverTestConnectButton` + the `IDriverProbe`
|
||||
path (transient config, no persistence) — probes now need the **merged
|
||||
Driver+Device config** (Batch 1's `DriverDeviceConfigMerger`).
|
||||
- **Enum-serialization guard:** every form/probe serialization site uses
|
||||
`JsonStringEnumConverter` (the systemic FB-9/FB-10 bug class); add a round-trip test
|
||||
per driver form model.
|
||||
|
||||
**B2-WP4 — manual tag entry + tag edit modal**
|
||||
|
||||
- `Add tags ▸ Manual entry` on Device/TagGroup: grid of name, datatype, access,
|
||||
`WriteIdempotent`, poll group + the driver-typed `TagConfig` editor per row (reuse
|
||||
`Components/Shared/Uns/TagEditors/*` via `TagConfigEditorMap` — re-keyed to
|
||||
`DriverTypeNames` in WP8). Single-tag Edit modal for the Tag node reuses the same
|
||||
editor shell.
|
||||
- Name validation inline (`RawPaths.Validate` — no `/`, no lead/trail whitespace) +
|
||||
sibling-uniqueness error surfaced from the service.
|
||||
- New editor registration for `Calculation` (see WP7): `CalculationTagConfigModel`
|
||||
(`scriptId`, `changeTriggered`, `timerIntervalMs`) + editor razor reusing the
|
||||
VirtualTagModal's script dropdown/"New script"/inline Monaco panel components.
|
||||
|
||||
**B2-WP5 — CSV import/export**
|
||||
|
||||
- Uses T0-2 parser. Staged flow (all new code — the old `EquipmentImportBatch` staged
|
||||
tables were orphaned dead code and are already dropped): upload → parse → **review
|
||||
grid** (per-row validation verdicts) → commit.
|
||||
- Columns per the design appendix: common
|
||||
`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized,
|
||||
HistorianTagname, IsArray, ArrayLength, Alarm.AlarmType, Alarm.Severity,
|
||||
Alarm.HistorizeToAveva, TagConfigJson` + the per-driver typed columns table (Modbus
|
||||
`Region, Address, ModbusDataType, ByteOrder, BitIndex, StringLength, Writable`; S7
|
||||
`Address, S7DataType, StringLength, Writable`; AbCip `TagPath, AbCipDataType,
|
||||
Writable`; AbLegacy `Address, AbLegacyDataType, Writable`; TwinCAT `SymbolPath,
|
||||
TwinCATDataType, Writable`; FOCAS `Address, FocasDataType`; OpcUaClient `NodeId`;
|
||||
Galaxy `AttributeRef`; Calculation `ScriptId, ChangeTriggered, TimerIntervalMs`).
|
||||
Enum columns accept member names case-insensitively. `TagGroupPath` auto-creates
|
||||
nested groups. `TagConfigJson` is the fallback for unmapped keys and **merges under**
|
||||
typed columns (typed column wins on conflict — document in the review grid).
|
||||
- Export produces the same shape (round-trip property test: export → import → identical
|
||||
tag set).
|
||||
- Mapping layer: per-driver `CsvColumnMap` derived from the `<Driver>TagConfigModel`
|
||||
scalar surfaces — put it next to the models in `AdminUI/Uns/TagEditors/` so model and
|
||||
map evolve together; a reflection test asserts every typed column matches a model
|
||||
property.
|
||||
|
||||
### Wave C — browse + Calculation + canonicalization (3 agents, parallel)
|
||||
|
||||
**B2-WP6 — browse re-target (GATED: requires the universal `DiscoveryDriverBrowser` merged pre-v3)**
|
||||
|
||||
Precondition check first: `IDriverBrowser` two-tier resolution + `DiscoveryDriverBrowser`
|
||||
+ `ITagDiscovery.SupportsOnlineDiscovery` exist (they do NOT as of 2026-07-15). If absent,
|
||||
this package detaches to a follow-up PR and the batch gate skips its leg.
|
||||
|
||||
- `Browse device…` on Device/TagGroup opens the browse modal reusing the session/tree
|
||||
layer as-is (`IBrowseSession`, `BrowseSessionRegistry`, `BrowserSessionService`,
|
||||
`DriverBrowseTree.razor`); enable/disable per the two-tier gate (bespoke browser →
|
||||
universal when `SupportsOnlineDiscovery` → grayed out + tooltip).
|
||||
- **Commit contract (v3):** multi-selected leaves → raw `Tag` rows under the target
|
||||
Device/TagGroup — `Name` from browse name, `DataType` from `DriverAttributeInfo`, the
|
||||
leaf's driver reference written into the driver-typed `TagConfig` **address field**
|
||||
(`nodeId` / `attributeRef` / the protocol address — NOT any identity key). Opt-in
|
||||
"create matching tag-groups" toggle mirrors browse folders onto nested `TagGroup`s.
|
||||
- **Config input:** pass the merged Driver+Device config (`DriverDeviceConfigMerger`) as
|
||||
the browser's `configJson` (the universal browser dials a real ephemeral driver
|
||||
instance and needs the device endpoint).
|
||||
- Name-collision note: the browser side must not reuse the name
|
||||
`CapturingAddressSpaceBuilder` — Runtime owns it
|
||||
(`Runtime/Drivers/CapturingAddressSpaceBuilder.cs`).
|
||||
|
||||
**B2-WP7 — `Calculation` driver (execute `2026-07-15-calculation-driver-mini-design.md` §§1–8 verbatim)**
|
||||
|
||||
- Project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` around the T0-4 evaluator:
|
||||
`CalculationDriver : IDriver, ISubscribable, IReadable` (not `IWritable`, not
|
||||
`ITagDiscovery`); the new **`IDependencyConsumer`** capability interface in
|
||||
`Core.Abstractions` (exact shape in mini-design §3); `DriverHostActor` wiring (~30
|
||||
lines): when a spawned driver implements it, spawn a mux-adapter child that
|
||||
`RegisterInterest(driver.DependencyRefs, Self)` on `DependencyMuxActor` and forwards
|
||||
`DependencyValueChanged` into `OnDependencyValue`; re-register on every apply.
|
||||
**Register-AND-consume trap:** the DoD includes a Runtime test proving a calc driver's
|
||||
deps actually flow (not just that the interface exists).
|
||||
- Triggers: change-gate à la `VirtualTagActor` (no publish until all deps seen; dedupe) +
|
||||
timer via the dormant `TimerTriggerScheduler` revived inside the driver.
|
||||
- Deploy gates in `DraftValidator`: `scriptId` existence in the draft generation; Tarjan
|
||||
cycle gate over calc→calc edges via the dormant `DependencyGraph` (cross-driver refs
|
||||
are terminal nodes). Compile deliberately NOT hard-gated (VT parity).
|
||||
- Error semantics: Bad + last-known value once per Good→Bad transition + `ScriptLogEntry`
|
||||
on `script-logs`; recovery force-published; historized Bad = `BadInternalError`.
|
||||
- Registration: `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` +
|
||||
`AddOtOpcUaDriverProbes` (probe = config-parse only, never throws); auto-created
|
||||
default `Engine` device; `DriverTypeNames.Calculation`.
|
||||
- Tests per mini-design §8 (unit + the 2-node harness integration case).
|
||||
|
||||
**B2-WP8 — `DriverTypeNames` rewire + old-page retirement**
|
||||
|
||||
- Re-key the four drifted dispatch maps to T0-3 constants:
|
||||
`DriverEditRouter._componentMap` (fixes `TwinCat`→`TwinCAT`, `Focas`→`FOCAS`),
|
||||
`AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`, `TagConfigValidator.cs`, and
|
||||
`ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs`. Add `Calculation`
|
||||
entries where applicable.
|
||||
- Once `/raw` covers authoring end-to-end (this wave), retire the routed
|
||||
`/clusters/{id}/drivers` flow: `DriverTypePicker`, `DriverEditRouter`, the 8 page
|
||||
shells (their extracted form bodies live on in the modals). Nav + any deep links
|
||||
updated.
|
||||
|
||||
## Wave schedule
|
||||
|
||||
| Wave | Packages | Agents | Notes |
|
||||
|---|---|---|---|
|
||||
| 0 | Merge Track 0 outputs onto the branch | — | T0-1..T0-4 |
|
||||
| A | WP1 ∥ WP2 | 2 | Service vs components — disjoint; WP2 stubs modals |
|
||||
| B | WP3 ∥ WP4 ∥ WP5 | 3 | Disjoint: driver forms vs tag editors/grid vs CSV. All three consume WP1/WP2 |
|
||||
| C | WP6 ∥ WP7 ∥ WP8 | 3 | WP6 gated on the universal browser; WP7 is mostly Runtime/driver-side (disjoint from UI packages); WP8 last-merged in the wave (it deletes pages others may still reference — coordinator merges WP8 after WP3/WP6) |
|
||||
|
||||
## Batch verification gate (live `/run` on docker-dev — authoring + probe only)
|
||||
|
||||
Rebuild **both** central nodes, `docker compose up -d`, bring up fixtures
|
||||
(`lmxopcua-fix up modbus standard`, `lmxopcua-fix up s7 s7_1500`). Then on
|
||||
`http://localhost:9200` (login disabled):
|
||||
|
||||
1. `/raw`: create Folder → Modbus driver (form body renders, enum fields correct) →
|
||||
Device with endpoint `10.100.0.35:5020` in `DeviceConfig` → **Test connect green**.
|
||||
2. TagGroup → Manual entry: add 3 tags incl. one historized with a >255-char effective
|
||||
tagname → **deploy blocked by DraftValidator naming the tag**; shorten/override →
|
||||
deploy succeeds (POST `/api/deployments`, `X-Api-Key`).
|
||||
3. CSV: export the device's tags, delete one, re-import the file → review grid shows the
|
||||
re-add, commit restores it. Import a file with a bad enum value → row-level error in
|
||||
the grid, no partial commit.
|
||||
4. Browse (if WP6 landed): browse an AbCip/OpcUaClient device, multi-select leaves with
|
||||
"create matching tag-groups" → raw tags land under mirrored groups with correct
|
||||
DataType + address field.
|
||||
5. Calculation: create a `Calculation` driver → author a calc tag whose script reads the
|
||||
Modbus tag's RawPath → deploy succeeds; author a 2-cycle (A reads B, B reads A) →
|
||||
**deploy blocked naming the cycle members**. (Computed values are NOT visible yet —
|
||||
Batch 4; the script-log page must stay silent, no crash loops in
|
||||
`docker compose logs`.)
|
||||
6. Rename a driver → the UI warning seam fires for a historized tag beneath it.
|
||||
7. Confirm the retired `/clusters/{id}/drivers` routes are gone from nav and 404/redirect
|
||||
cleanly.
|
||||
|
||||
Record each step's evidence in the PR.
|
||||
@@ -0,0 +1,112 @@
|
||||
# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}` reference-relative resolution
|
||||
|
||||
**Branch:** `v3/batch3-uns-rework` (from master after Batch 2 merges)
|
||||
**Design authority:** v3 design §"UNS Equipment — reference-only", §"Scripting — paths and `{{equip}}` under v3", §"Error handling / edge cases" (effective-name collision, rename-warning scan).
|
||||
**End state:** Equipment holds `UnsTagReference` rows instead of authored tags; scripts resolve `{{equip}}/<RefName>` through references; every collision/unresolved case is a clear deploy error. Address space still dark for values (Batch 4), so verification is authoring + deploy-gate level.
|
||||
|
||||
## Work packages
|
||||
|
||||
### Wave A (3 agents, parallel)
|
||||
|
||||
**B3-WP1 — Equipment Tags tab → reference list + raw-tag picker**
|
||||
|
||||
- `Components/Pages/Uns/EquipmentPage.razor` Tags tab: replace the Batch-1-stubbed
|
||||
authored-tag flow with a `UnsTagReference` list — columns: effective name, raw path,
|
||||
inherited datatype/access (read-only, from the raw tag), display-name override,
|
||||
remove. `TagModal.razor`'s driver-binding path is deleted (VirtualTag/ScriptedAlarm
|
||||
modals untouched).
|
||||
- **"+ Add reference"** opens a raw-tree picker modal reusing Batch 2's `RawTree`
|
||||
component in picker mode (multi-select checkboxes on Tag leaves; Device/TagGroup
|
||||
select-all), **scoped to the equipment's cluster** (cross-cluster structurally
|
||||
impossible — the service query is cluster-filtered, not just the UI).
|
||||
- `ImportEquipmentModal.razor` + `Uns/EquipmentInput.cs` + `UnsTreeService.ImportEquipmentAsync`:
|
||||
drop the `DriverInstanceId` column (equipment no longer carries a driver).
|
||||
- Service mutations in `UnsTreeService` (+ `IUnsTreeService`): add/remove references,
|
||||
set override — each enforcing WP2's uniqueness check and returning readable errors.
|
||||
|
||||
**B3-WP2 — effective-name uniqueness (authoring + deploy gate)**
|
||||
|
||||
- Rule: within an equipment, effective name (`DisplayNameOverride` else raw `Name`) is
|
||||
unique across **references, VirtualTags, and ScriptedAlarms**.
|
||||
- Authoring-time: service-level check in every mutation that can collide (add reference,
|
||||
set override, add/rename VirtualTag or ScriptedAlarm).
|
||||
- Deploy-time: `DraftValidator` gains the same rule over the draft snapshot — this is
|
||||
what catches **rename-induced** collisions (a raw rename the authoring check never
|
||||
saw); the error names both colliding sources and the equipment.
|
||||
- `DraftSnapshotFactory` extended to load `UnsTagReference` + raw-tag names into the
|
||||
snapshot.
|
||||
- Unit tests: authoring rejection, deploy rejection post-rename, override-vs-VT clash,
|
||||
case-sensitivity pinned (ordinal, matching NodeId semantics).
|
||||
|
||||
**B3-WP3 — rename-warning script scan**
|
||||
|
||||
- Extend Batch 2's rename-warning seam in `RawTreeService`: on rename of a tag or any
|
||||
ancestor, compute affected RawPaths (prefix scan) and warn when a tag is (a)
|
||||
historized without a `historianTagname` override (history forks), (b) UNS-referenced
|
||||
(names the equipment), (c) matched by a **substring scan of `Script` bodies for the
|
||||
old RawPath** (service-level, tolerates false positives — decided in the design; no
|
||||
new bookkeeping). `{{equip}}`-relative refs need no scan (they resolve through
|
||||
references; the deploy gate catches breakage).
|
||||
- UI: warning list in the rename confirm dialog; rename proceeds on confirm
|
||||
(Kepware-equivalent, deliberate).
|
||||
|
||||
### Wave B (1 agent — single integration point)
|
||||
|
||||
**B3-WP4 — `{{equip}}` reference-relative resolution**
|
||||
|
||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs`:
|
||||
**delete `DeriveEquipmentBase`** (shared first-dot prefix of child FullNames — cannot
|
||||
survive reference-only equipment). `SubstituteEquipmentToken` is replaced by
|
||||
**per-reference resolution**: `{{equip}}/<RefName>` → look up the equipment's
|
||||
`UnsTagReference` rows by effective name → substitute the backing RawPath. Token
|
||||
syntax note: today's derivation used a **dot** joint (`{{equip}}.X`); v3's is
|
||||
slash-shaped (`{{equip}}/<RefName>`) per the design — update `ExtractDependencyRefs` /
|
||||
`ExtractAlarmDependencyRefs` and the `EquipToken` handling accordingly, and pin the
|
||||
new syntax in tests.
|
||||
- The **same two compose seams** substitute as today:
|
||||
`OpcUaServer/AddressSpaceComposer.cs` (~line 210 branch) and
|
||||
`Runtime/Drivers/DeploymentArtifact.cs` (`SubstituteEquipmentToken` call, line ~563) —
|
||||
both now receive the equipment's reference map instead of a derived base prefix.
|
||||
- **Unresolved `<RefName>` = deploy-time validation error** (in `DraftValidator`, where
|
||||
the reference map + script sources are both in the snapshot), naming the script, the
|
||||
equipment, and the missing ref name — replacing today's silent null-base
|
||||
no-substitution. Alarm `{TagPath}` message tokens follow the same rule.
|
||||
- Monaco/editor parity: `AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` — the
|
||||
`{{equip}}` completion branch (lines ~176–182) now completes **reference effective
|
||||
names** for the equipment context; the diagnostics branch (~280–284) flags unresolved
|
||||
refs identically to the deploy gate (editor accepts ⇔ publish accepts — the
|
||||
established invariant). `IScriptTagCatalog` gains the per-equipment reference list;
|
||||
absolute-path completions list RawPaths (already moved in Batch 1's catalog rework —
|
||||
verify, don't assume).
|
||||
- Tests: substitution unit tests (resolved, unresolved→error, override-named ref,
|
||||
alarm-token), composer/artifact parity tests, ScriptAnalysis completion/diagnostic
|
||||
tests.
|
||||
|
||||
## Wave schedule
|
||||
|
||||
| Wave | Packages | Agents | Parallel-safe because |
|
||||
|---|---|---|---|
|
||||
| A | WP1 ∥ WP2 ∥ WP3 | 3 | WP1 owns Razor + service mutation methods; WP2 owns validator/snapshot + service *check* helpers (shared file `UnsTreeService.cs` — WP1 owns it, WP2 delivers its check as a separate injectable `EffectiveNameGuard` consumed by WP1); WP3 owns `RawTreeService` warning code |
|
||||
| B | WP4 | 1 | Touches Commons + composer + artifact + validator + ScriptAnalysis — single integration agent, rebased on Wave A |
|
||||
|
||||
## Batch verification gate (live `/run` on docker-dev)
|
||||
|
||||
Rebuild **both** centrals; fixtures up; Batch 2's raw tags present (re-author or re-seed).
|
||||
|
||||
1. Equipment page: "+ Add reference" picker is cluster-scoped (raw tags of another
|
||||
cluster absent), multi-select pulls 3+ tags at once; rows show raw path + inherited
|
||||
type/access; set a `DisplayNameOverride` → effective name updates.
|
||||
2. Collision: add a reference whose effective name equals an existing VirtualTag →
|
||||
**authoring rejected** with both sources named. Then force a rename-induced clash
|
||||
(rename the raw tag in `/raw` so two references collide) → authoring allowed at
|
||||
rename, **deploy blocked** by `DraftValidator` naming both.
|
||||
3. `{{equip}}`: author a VirtualTag script using `ctx.GetTag("{{equip}}/<RefName>")` —
|
||||
Monaco completes the reference names and shows no diagnostic; deploy succeeds.
|
||||
Misspell the ref → Monaco diagnostic AND deploy error agree (same message family).
|
||||
4. Rename warning: rename a raw tag that is historized + referenced + named in a script
|
||||
literal → confirm dialog lists all three warnings; renaming an unrelated tag warns
|
||||
nothing.
|
||||
5. ImportEquipmentModal: import runs without the driver column.
|
||||
|
||||
Deploys via `POST /api/deployments` (`X-Api-Key`). Values still dark — assert nothing
|
||||
about live data. Record evidence in the PR.
|
||||
@@ -0,0 +1,170 @@
|
||||
# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)
|
||||
|
||||
**Branch:** `v3/batch4-address-space` (from master after Batch 3 merges)
|
||||
**Design authority:** v3 design §"OPC UA address space + runtime binding" (including the resolved multi-notifier SDK mechanism — the `AddNotifier` code block is normative), §"Error handling / edge cases".
|
||||
**End state:** both namespaces live; every value has exactly one source (the raw node's publish path) fanned to both NodeIds; writes, history, alarms, and scripts all work through either namespace. This batch landing = **v3.0**.
|
||||
|
||||
**This is the batch where the forwarding trap lives.** Every sink-interface change MUST
|
||||
forward through `DeferredAddressSpaceSink` and extend
|
||||
`tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs`
|
||||
— that is part of WP2's definition of done, not an afterthought.
|
||||
|
||||
## Namespace + NodeId scheme (contracts commit — coordinator lands first)
|
||||
|
||||
- Two namespace URIs (constants next to the current
|
||||
`OtOpcUaNodeManager.DefaultNamespaceUri = "https://zb.com/otopcua/ns"`, which they
|
||||
replace): **`https://zb.com/otopcua/raw`** and **`https://zb.com/otopcua/uns`**.
|
||||
- `ns=Raw` NodeIds: `s=<RawPath>`. `ns=UNS` NodeIds:
|
||||
`s=<Area>/<Line>/<Equipment>/<EffectiveName>`.
|
||||
- New `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs` (raw + UNS path builders,
|
||||
consuming `RawPaths`); `EquipmentNodeIds.cs` (`{EquipmentId}/{FolderPath}/{Name}`
|
||||
logical-id scheme) is **retired** — delete it and sweep callers
|
||||
(`EquipmentNodeWalker`, composer, tests).
|
||||
- Sink discriminator (decided here, used by WP2/WP3): sink methods carry an
|
||||
`AddressSpaceRealm` enum (`Raw` | `Uns`) rather than parsing namespace out of NodeId
|
||||
strings — explicit beats inferred at every call site.
|
||||
|
||||
## Work packages
|
||||
|
||||
### Wave A — composition + node manager (2 agents)
|
||||
|
||||
**B4-WP1 — composer/planner emit both subtrees**
|
||||
|
||||
- `OpcUaServer/AddressSpaceComposer.cs` (+ `AddressSpaceComposition`,
|
||||
`AddressSpaceChangeClassifier`): un-darken. Compose the Raw subtree
|
||||
(Cluster→Folder→Driver→Device→TagGroup→Tag) from the schema — folders/drivers/devices/
|
||||
groups as Object/Folder nodes, tags as Variables keyed `(realm=Raw, s=<RawPath>)` —
|
||||
and the UNS subtree (Area/Line/Equipment folders; each `UnsTagReference` a Variable
|
||||
keyed `(realm=Uns, s=<equipment path>/<effective name>)`) carrying its backing RawPath
|
||||
+ an `Organizes` reference UNS→Raw. Native-alarm plans attach at the **raw** tag
|
||||
(ConditionId = RawPath) and carry the list of referencing equipment paths.
|
||||
- `AddressSpacePlan.cs` (`AddressSpacePlanner.Compute`) diffs per realm; a raw rename
|
||||
manifests as remove+add in Raw **and** re-point in UNS (reference row unchanged, backing
|
||||
NodeId changed) — pin this in planner tests.
|
||||
- Reactivate the Batch-1-skipped harness assertion
|
||||
(`EquipmentNamespaceMaterializationTests` skip-reason constant) with the new dual-tree
|
||||
expectations.
|
||||
|
||||
**B4-WP2 — node manager dual-namespace + sink surface (owns the forwarding trap)**
|
||||
|
||||
- `OpcUaServer/OtOpcUaNodeManager.cs`: register both namespace URIs; node creation/
|
||||
`EnsureVariable`/`CreateVariable` take the realm; `_historizedTagnames`,
|
||||
`_notifierFolders`, and the write hook wiring become realm-aware.
|
||||
- Sink interfaces `Commons/OpcUa/IOpcUaAddressSpaceSink.cs` +
|
||||
`ISurgicalAddressSpaceSink.cs`: add the `AddressSpaceRealm` discriminator (or
|
||||
realm-qualified node descriptors) to every method that names nodes; update
|
||||
`SdkAddressSpaceSink`, **forward every change through `DeferredAddressSpaceSink`**, and
|
||||
extend `DeferredSinkForwardingReflectionTests` so an unforwarded method fails CI.
|
||||
- HistoryRead access bits: the UNS reference node registers the **same** historian
|
||||
tagname in `_historizedTagnames` (both NodeIds → one tagname; the mux
|
||||
`HistorizedTagRef` stays single, keyed by RawPath — `AddressSpaceApplier.FeedHistorizedRefs`
|
||||
emits raw refs only, no doubles).
|
||||
|
||||
### Wave B — runtime binding + writes (1 agent; the single most delicate package)
|
||||
|
||||
**B4-WP3 — raw-only binding, UNS fan-out, write routing**
|
||||
|
||||
- `Runtime/Drivers/DriverHostActor.cs`: on apply, register the raw NodeId AND every
|
||||
referencing UNS NodeId against the same `(DriverInstanceId, RawPath)` in
|
||||
`_nodeIdByDriverRef` (the map is already 1:N — this is new entries, not new machinery);
|
||||
`_driverRefByNodeId` gains the UNS NodeId → same ref inverse. Alarm maps
|
||||
(`_alarmNodeIdByDriverRef`/`_driverRefByAlarmNodeId`) likewise carry the equipment
|
||||
notifier NodeIds. `ForwardToMux` unchanged (mux keys stay RawPath-single).
|
||||
- Write path: a write to a UNS NodeId resolves through `_driverRefByNodeId` to the same
|
||||
driver ref — same `WriteOperate` gating, same `RouteNodeWrite` flow. `OnWriteValue`
|
||||
stays fire-and-forget under the node-manager lock. **Write-outcome self-correction
|
||||
(#5) must keep working through both NodeIds**: a failed device write reverts BOTH node
|
||||
states (they share the stored value only via the fan-out — the revert is a publish, so
|
||||
it fans automatically; add the regression test anyway).
|
||||
- `AddressSpaceApplier.cs`: apply both realms; surgical updates (the F10b in-place path)
|
||||
realm-aware; UNS display-name override changes are surgical (no rebuild), raw renames
|
||||
follow WP1's remove+add plan.
|
||||
- Tests: `DriverHostActorLiveValueTests`/`WriteRoutingTests`/`NativeAlarmTests` extended
|
||||
with dual-NodeId cases; a fan-out drift test (publish once → both node states carry
|
||||
identical value/quality/timestamp).
|
||||
|
||||
### Wave C — alarms + verification hardening (2 agents)
|
||||
|
||||
**B4-WP4 — multi-notifier alarms**
|
||||
|
||||
- Materialize each native alarm ONCE at the raw tag (parent = its device/group folder,
|
||||
promoted via `EnsureFolderIsEventNotifier` — existing method, line ~1286). For each
|
||||
referencing equipment's UNS folder, wire the design's normative pattern:
|
||||
`alarm.AddNotifier(ctx, null, isInverse: true, equipFolder)` +
|
||||
`equipFolder.AddNotifier(ctx, null, isInverse: false, alarm)` +
|
||||
`EnsureFolderIsEventNotifier(equipFolder)`. One `ReportEvent` fans to all roots;
|
||||
**never duplicate ReportEvent per root** (distinct EventIds break Server-object dedup
|
||||
+ Part 9 ack correlation — design decision, not implementer discretion).
|
||||
- **Teardown symmetry (implementation obligation):** track wired notifier pairs like
|
||||
`_notifierFolders` and call `RemoveNotifier(..., bidirectional: true)` on
|
||||
rebuild/subtree-removal/reference-removal — inverse-notifier entries otherwise leak
|
||||
across redeploys.
|
||||
- Part 9 ack/confirm/shelve keep routing on **ConditionId = RawPath** (never
|
||||
SourceNodeId); the `AlarmAck` role gate and the `alarm-commands` DPS flow are
|
||||
unchanged.
|
||||
- `Commons/Messages/Alerts/AlarmTransitionEvent.cs` gains the (possibly empty) list of
|
||||
referencing equipment paths; `/alerts` (`AlertHub` + `Alerts.razor`) shows one row per
|
||||
condition (primary identity RawPath + condition NodeId) with the equipment list as
|
||||
display metadata.
|
||||
- `NativeAlarmProjector` + scripted-alarm surfaces untouched except NodeId scheme sweeps.
|
||||
|
||||
**B4-WP5 — integration tests + docs**
|
||||
|
||||
- 2-node harness: dual-namespace materialization, UNS-write-reaches-driver, alarm event
|
||||
at both notifiers, HistoryRead via both NodeIds (NullHistorianDataSource `GoodNoData`
|
||||
path suffices offline), redundancy: ServiceLevel + alarm-emit gate unaffected by the
|
||||
second namespace (the redundancy-state delivery bugs were rig-only — verify on the
|
||||
2-node rig, not just unit tests).
|
||||
- Docs in the same PR: `CLAUDE.md` (address-space + tag-concept sections), `docs/Uns.md`,
|
||||
`docs/ScriptEditor.md`, `docs/Historian.md`, `docs/ScriptedAlarms.md`,
|
||||
`docs/AlarmTracking.md`; the ScadaBridge cutover note + umbrella-index update
|
||||
(`../scadaproj/CLAUDE.md`) per the design's cross-repo plan.
|
||||
|
||||
## Wave schedule
|
||||
|
||||
| Wave | Packages | Agents | Notes |
|
||||
|---|---|---|---|
|
||||
| — | Contracts commit (URIs, `V3NodeIds`, `AddressSpaceRealm`) | coordinator | Before any fan-out |
|
||||
| A | WP1 ∥ WP2 | 2 | Composer/planner vs node-manager/sinks — disjoint files; both build against the contracts commit |
|
||||
| B | WP3 | 1 | Runtime binding integrates A's output — single agent |
|
||||
| C | WP4 ∥ WP5 | 2 | Alarms vs tests/docs — WP5 rebases last |
|
||||
|
||||
## Batch verification gate — the v3.0 live gate (docker-dev + Client.CLI)
|
||||
|
||||
Rebuild **both** centrals; fixtures up (`modbus standard` at minimum); deploy a config
|
||||
with: a Modbus raw tag (historized), a Calculation tag reading it, an equipment
|
||||
referencing both (one with a display-name override), a native alarm on the Modbus tag,
|
||||
and a VirtualTag using `{{equip}}/<RefName>`.
|
||||
|
||||
1. **Browse both namespaces** (Client.CLI `browse -r -d 6`): Raw shows
|
||||
Folder→Driver→Device→Group→Tag with `s=<RawPath>` NodeIds; UNS shows
|
||||
Area/Line/Equipment with effective names; the UNS variable `Organizes`-references its
|
||||
raw node.
|
||||
2. **Single-source fan-out:** `subscribe` to the raw NodeId and the UNS NodeId
|
||||
simultaneously → identical values/timestamps as the fixture changes; the calc tag
|
||||
computes; break its script → Bad + script-log row; fix → recovery.
|
||||
3. **Writes:** `write` via the UNS NodeId → device changes (read back via raw NodeId);
|
||||
repeat via raw NodeId. Role gating: an LDAP user without `WriteOperate` is rejected
|
||||
on both. Failed-write revert: enable the modbus `exception_injector` FC06 rule →
|
||||
write via UNS NodeId → both NodeIds revert to prior value (no optimistic-Good
|
||||
phantom). Remember Client.CLI `write` reads first — target a Good node.
|
||||
4. **History:** `historyread` against the raw NodeId and the UNS NodeId returns the same
|
||||
series under the same historian tagname (live gateway if VPN available, else
|
||||
`GoodNoData` parity from the Null source); provisioning tally logs
|
||||
`dispatched=N, failed=0`.
|
||||
5. **Alarms:** trip the native alarm → `/alerts` shows ONE row carrying the equipment
|
||||
path list; Client.CLI `alarms` subscribed at (a) the Server object — exactly **one**
|
||||
event per transition (the shared-snapshot dedup check), (b) the raw device folder,
|
||||
(c) the equipment folder — each receives the event; `ack` via the equipment-side
|
||||
subscription works (ConditionId routing). Redeploy (rebuild path) then re-trip →
|
||||
still exactly one Server-object copy (teardown-symmetry check: no leaked notifier
|
||||
duplicates).
|
||||
6. **Rename cascade:** rename the raw tag in `/raw` (accept the warnings) + deploy →
|
||||
old raw NodeId gone, new present; UNS reference follows automatically; the absolute
|
||||
RawPath script literal now fails as documented (`BadNodeIdUnknown` at evaluation);
|
||||
the `{{equip}}` VirtualTag keeps working (reference-relative).
|
||||
7. **Redundancy sanity** on the 2-node rig: ServiceLevel 240/100 split intact; alarm
|
||||
rows single (no double-emit from the second namespace).
|
||||
|
||||
All seven legs recorded with evidence in the PR. Merge = tag/announce **v3.0**; then the
|
||||
cross-repo follow-ups (ScadaBridge re-bind coordination, umbrella index) per WP5.
|
||||
@@ -0,0 +1,162 @@
|
||||
# v3 implementation plan — master orchestration
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Design:** `docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` (all open items resolved; required reading for every agent)
|
||||
**Companion designs:** `2026-07-15-calculation-driver-mini-design.md` (Batch 2), `2026-07-15-universal-discovery-browser-design.md` (pre-v3 dependency)
|
||||
**Batch plans:** `2026-07-15-v3-batch1-schema-identity-plan.md` · `2026-07-15-v3-batch2-raw-ui-calculation-plan.md` · `2026-07-15-v3-batch3-uns-rework-plan.md` · `2026-07-15-v3-batch4-address-space-plan.md`
|
||||
**Executors:** Opus agents. This document is the coordinator's contract: batch order, parallel lanes, merge discipline, and the non-negotiable verification gates.
|
||||
|
||||
## 1. Shape of the work
|
||||
|
||||
Four **sequential batches** (each = one feature branch + one PR to master, merged only after
|
||||
its live gate passes), plus one **batch-independent track (Track 0)** that can run in
|
||||
parallel with Batch 1 because it touches nothing Batch 1 touches.
|
||||
|
||||
```
|
||||
Track 0 (parallel with Batch 1): ContextMenu component · RFC-4180 CSV parser ·
|
||||
DriverType constants · Calculation evaluator core
|
||||
│
|
||||
Batch 1 (schema + RawPath identity) ──► Batch 2 (/raw UI + Calculation driver)
|
||||
──► Batch 3 (UNS reference-only + {{equip}}) ──► Batch 4 (dual-namespace
|
||||
address space) = v3.0
|
||||
```
|
||||
|
||||
**Precondition:** the universal `DiscoveryDriverBrowser`
|
||||
(`2026-07-15-universal-discovery-browser-design.md`) is a **pre-v3 dependency that is NOT
|
||||
in the tree yet** (verified 2026-07-15: no `DiscoveryDriverBrowser` exists;
|
||||
`ITagDiscovery` has no `SupportsOnlineDiscovery` member — the browser design adds it).
|
||||
Only Batch 2 work package **B2-WP6 (browse re-target)** is blocked on it. Everything else
|
||||
proceeds; if the browser slips, B2-WP6 detaches into a follow-up PR and the Batch 2 gate
|
||||
runs without the browse-commit leg.
|
||||
|
||||
**Why the batches cannot overlap:** each batch's live gate deploys the previous batch's
|
||||
schema/UI to docker-dev. Batch 2 authors against Batch 1's schema; Batch 3 references
|
||||
Batch 2's raw tags; Batch 4 lights up what 1–3 authored. Parallelism lives *inside*
|
||||
batches (see each batch plan's wave schedule), not between them.
|
||||
|
||||
## 2. Agent execution model
|
||||
|
||||
- **One coordinator agent per batch.** It reads the design + the batch plan, spawns
|
||||
implementation agents per work package, owns the batch feature branch, and is the only
|
||||
agent that merges.
|
||||
- **Implementation agents run in isolated git worktrees** (`isolation: worktree` /
|
||||
`EnterWorktree`). Hard rule from project history: parallel implementers on a shared
|
||||
checkout race the git index and corrupt each other's staging — never share a tree.
|
||||
- **Contract-first fan-out.** When a wave shares new types (e.g. the `RawPaths` helper,
|
||||
the reshaped entities), the coordinator lands a small **contracts commit** on the batch
|
||||
branch first; wave agents branch from it. No agent invents a shared type mid-wave.
|
||||
- **File-ownership discipline.** The wave tables in each batch plan assign files to
|
||||
exactly one work package per wave. If an agent finds it must touch a file owned by
|
||||
another package, it stops and reports — the coordinator re-slices rather than letting
|
||||
two agents edit one file.
|
||||
- **Merge order** is the wave order. After each wave merges, the coordinator runs
|
||||
`dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test ZB.MOM.WW.OtOpcUa.slnx` before
|
||||
starting the next wave. A red build blocks the next wave; the coordinator fixes or
|
||||
reverts, never "fixes forward" into a parallel wave.
|
||||
- **Review checkpoint per wave:** a code-reviewer agent reviews the wave diff against the
|
||||
batch plan before merge. Findings are fixed in the worktree, not post-merge.
|
||||
- **Each batch ends with the live `/run` gate executed by an agent with browser tools**
|
||||
(the docker-dev AdminUI has `Security__Auth__DisableLogin: "true"` — no sign-in needed;
|
||||
drive `http://localhost:9200` directly). **Live gates are non-negotiable**: this project
|
||||
has repeatedly shipped prod-inert code that 250+ unit tests and three reviews missed
|
||||
(the `DeferredAddressSpaceSink` forwarding trap, the dormant `GatewayTagProvisioner`).
|
||||
A batch is not done until its live-gate checklist passes.
|
||||
|
||||
## 3. Track 0 — batch-independent work (start immediately, parallel with Batch 1)
|
||||
|
||||
Four packages with zero overlap with Batch 1's files. Each is a separate worktree +
|
||||
separate small PR (or merged onto the Batch 2 branch when it opens).
|
||||
|
||||
| ID | Package | New files (nothing existing is touched) | Verification |
|
||||
|---|---|---|---|
|
||||
| T0-1 | **`ContextMenu` Blazor component** — reusable `oncontextmenu` menu with keyboard/touch fallback (explicit "⋯" affordance). No right-click menu exists anywhere in the Blazor app today (the only `ContextMenu` is in the Avalonia desktop client — do not confuse them). | `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor` (+ `.razor.css`), `ContextMenuItem.cs` | Demo host page under a dev-only route; manual live check on docker-dev (AdminUI has **no bUnit** — Razor bugs must be live-verified) |
|
||||
| T0-2 | **RFC-4180 CSV parser** — quoted fields, embedded commas/quotes/newlines, CRLF/LF; plus a writer that quotes on demand. Pure, no I/O. No CSV code exists in the repo today (verified) — greenfield. | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Csv/CsvParser.cs`, `CsvWriter.cs`; tests in `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Csv/` | Unit suite incl. RFC 4180 edge corpus (quoted quote, trailing newline, empty field vs absent field) |
|
||||
| T0-3 | **`DriverTypeNames` constants class** — single source of truth for driver-type strings, authority = the factories' `DriverTypeName` (`TwinCAT`, `FOCAS`, `GalaxyMxGateway`). Consumed later by the four drifted maps (`DriverEditRouter._componentMap` currently keys `TwinCat`/`Focas`; `TagConfigEditorMap`; `TagConfigValidator`; `EquipmentTagConfigInspector` in **ControlPlane**, not AdminUI). T0-3 only *adds* the constants + a reflection test asserting they match every registered factory; the four consumers are rewired in Batch 2 (B2-WP8). | `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeNames.cs`; test in `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/` (or Commons.Tests if no such project) | Reflection test: every `DriverTypeName` exposed by `DriverFactoryBootstrap.AddOtOpcUaDriverFactories` registrations has a matching constant, and vice versa |
|
||||
| T0-4 | **Calculation evaluator core** — `CalculationEvaluator` built like `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`): `CompiledScriptCache` + `TimedScriptEvaluator` (2 s) + `VirtualTagContext`, single-tag mode (`ctx.SetVirtualTag` dropped + logged). Pure engine + unit tests only; the driver shell that hosts it is Batch 2 (B2-WP7). See mini-design §4. | New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/` (evaluator + tests only at this stage) | Parity unit tests vs the VT evaluator: passthrough fast-path, timeout, sandbox violation, SetVirtualTag drop |
|
||||
|
||||
## 4. Batch summaries + gates (detail in the batch plans)
|
||||
|
||||
| Batch | Branch | Content | Gate (summary — full recipe in the batch plan) |
|
||||
|---|---|---|---|
|
||||
| 1 | `v3/batch1-schema-identity` | Greenfield EF schema (RawFolder/Device-universal/TagGroup/Tag-raw-only/UnsTagReference; Namespace + EquipmentImportBatch retired); `RawPaths` identity helper; `TagConfigIntent` sheds FullName; all 8 drivers' resolvers keyed by RawPath (blob-fallback parsers retired); endpoint→`DeviceConfig` move; composer/artifact/DraftValidator/UnsTreeService rewired; seeds + golden corpus rewritten. **Address space intentionally dark.** | Full solution build + test green (incl. rewritten corpus); `V3Initial` migration applies to a fresh DB; docker-dev re-seeds and both nodes boot with drivers Connected (values not yet exposed — that's Batch 4) |
|
||||
| 2 | `v3/batch2-raw-ui` | `/raw` project tree (lazy loading + T0-1 menus), driver config/device modals (typed pages refactored to embeddable bodies), tag manual entry + CSV import/export (T0-2 + decided column dictionaries), browse re-target (gated on universal browser), `Calculation` driver (T0-4 + mini-design), `DriverTypeNames` rewire | Live `/run` on `:9200`: author folder→driver→device→tags in-tree; Test-connect green vs a fixture; CSV round-trip; browse-commit raw tags (if browser landed); author + deploy a calc tag. Proves authoring + probe only — no live values until Batch 4 |
|
||||
| 3 | `v3/batch3-uns-rework` | Equipment Tags tab → reference list + cluster-scoped multi-select raw picker; effective-name uniqueness (authoring + DraftValidator); `{{equip}}` reference-relative resolution + unresolved-ref deploy error; rename-warning substring scan | Live `/run`: reference raw tags into an equipment; display-name override shows; deploy gate rejects a reference collision and an unresolved `{{equip}}` ref |
|
||||
| 4 | `v3/batch4-address-space` | Dual namespaces, RawPath NodeIds, UNS fan-out registration, raw-only runtime binding, write routing via either NodeId, alarm multi-notifier (`AddNotifier` pattern + teardown symmetry), historian dual-registration, sink namespace discriminator + `DeferredAddressSpaceSink` forwarding + reflection-test extension | Live `/run` + Client.CLI: browse both namespaces; read/subscribe/write through both NodeIds; HistoryRead via both; native alarm visible at raw + equipment notifiers with exactly one Server-object copy; `{{equip}}` script computes; failed-write revert still works. **= v3.0** |
|
||||
|
||||
## 5. Global conventions (binding for every agent)
|
||||
|
||||
- .NET 10, C#, xUnit + **Shouldly**, Serilog. Match surrounding code style; comments only
|
||||
for constraints the code can't show.
|
||||
- JSON config keys are **camelCase**; enum values serialize as **string names**
|
||||
(`JsonStringEnumConverter`). Systemic past bug: AdminUI pages serialized enums
|
||||
numerically while DTOs were string-typed → authored configs faulted drivers. Every new
|
||||
serialization site gets the converter, and every new editor gets a round-trip test.
|
||||
- Editors/models **preserve unknown JSON keys** (the established `<Driver>TagConfigModel`
|
||||
`FromJson`/`ToJson` contract). .NET 10 `JsonNode.ToJsonString` gotchas are documented in
|
||||
the existing models — copy the Modbus template.
|
||||
- **Do not enable** `CentralPackageTransitivePinningEnabled` (breaks the Roslyn build via
|
||||
the 5.0.0/4.12.0 split). Transitive CVE fixes = surgical direct `PackageReference` at a
|
||||
common-ancestor project.
|
||||
- EF migrations via `dotnet ef` with `DesignTimeDbContextFactory` (reads
|
||||
`OTOPCUA_CONFIG_CONNECTION`, defaults to `10.100.0.35,14330`). All model config lives
|
||||
inline in `OtOpcUaConfigDbContext.OnModelCreating` — keep it there (no
|
||||
`IEntityTypeConfiguration` files exist; don't introduce the pattern mid-stream).
|
||||
- Blazor: string component parameters need the `@` prefix when passing C# expressions
|
||||
(the F15 gotcha that bit Global UNS); no bUnit exists — **live-verify every Razor
|
||||
change** on docker-dev.
|
||||
- Name collision: Runtime already owns
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/CapturingAddressSpaceBuilder.cs` — any
|
||||
browser-side capturing builder must use a different name or namespace-qualify.
|
||||
|
||||
## 6. Global gotchas (project scar tissue — read before coding)
|
||||
|
||||
1. **The forwarding trap (highest-severity recurring bug):** any new or changed method on
|
||||
`IOpcUaAddressSpaceSink` / `ISurgicalAddressSpaceSink`
|
||||
(`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/`) **must** be forwarded through
|
||||
`DeferredAddressSpaceSink` and covered by
|
||||
`tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/DeferredSinkForwardingReflectionTests.cs`.
|
||||
A non-forwarded method compiles, passes unit tests, and is silently dead on driver
|
||||
hosts in production. Same family: registering a service in DI without passing it into
|
||||
its consumer (the dormant-`GatewayTagProvisioner` bug) — every "register X" step needs
|
||||
a "consume X" assertion.
|
||||
2. **`OnWriteValue` runs under the node-manager lock** — treat driver write dispatch as
|
||||
fire-and-forget (`Tell`, never blocking ask) exactly as `OnEquipmentTagWrite` does
|
||||
today (`OtOpcUaNodeManager.cs:1451`).
|
||||
3. **Resolve `DriverHostActor` lazily** from consumers wired at startup — the actor
|
||||
spawns after DI graph construction.
|
||||
4. **docker-dev `:9200` round-robins central-1/central-2** behind Traefik — rebuild and
|
||||
restart **both** (`docker compose build central-1 central-2 && docker compose up -d`)
|
||||
before trusting any AdminUI verification; kind-changing config edits need
|
||||
deploy-THEN-recreate.
|
||||
5. Historian provisioning: `StorageRateMs` must be **> 0** (proto default 0 throws
|
||||
gateway-side and silently fails all provisioning — PR #439).
|
||||
6. Client.CLI `write` **reads first** — you cannot write a node whose read is Bad. To
|
||||
force a protocol write failure on the rig, use the modbus fixture's
|
||||
`exception_injector` (FC06 reject rule on a seeded Good address).
|
||||
7. Data-plane role tests need `Security:Ldap:GroupToRole` in appsettings; LDAP is the
|
||||
shared GLAuth `10.100.0.35:3893` (all test users password `password`).
|
||||
8. Deploy API: `POST http://localhost:9200/api/deployments` with `X-Api-Key` from
|
||||
`Security:DeployApiKey` (self-disables 503 when unset). Direct SQL edits against the
|
||||
config DB need `SET QUOTED_IDENTIFIER ON`.
|
||||
9. Driver fixtures live on the Docker host `10.100.0.35`, controlled via
|
||||
`lmxopcua-fix ls|up|down|sync` from this VM (`docker -H ssh://` does NOT work).
|
||||
10. Native-alarm routing keys on **`ConditionId`** (the authored ref), never
|
||||
`SourceNodeId` — under v3 the ConditionId becomes the RawPath.
|
||||
|
||||
## 7. Deliverable checklist (coordinator ticks per batch)
|
||||
|
||||
- [ ] Batch branch opened from up-to-date master; contracts commit landed before fan-out.
|
||||
- [ ] Every wave: worktree-isolated agents, file-ownership respected, reviewer pass, green
|
||||
build+test after merge.
|
||||
- [ ] Batch-specific unit/integration suites listed in the batch plan all green, including
|
||||
the DB-backed suites (`Configuration.Tests` schema-compliance against
|
||||
`10.100.0.35,14330`) and the 2-node harness
|
||||
(`tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/`, default in-memory mode).
|
||||
- [ ] Live `/run` gate checklist from the batch plan executed and evidence recorded in the
|
||||
PR description (what was clicked/deployed/observed).
|
||||
- [ ] Docs touched by the batch updated in the same PR (`docs/Configuration.md`,
|
||||
`docs/Uns.md`, `docs/ScriptEditor.md`, `docs/Historian.md`, `CLAUDE.md` sections
|
||||
that describe replaced behavior).
|
||||
- [ ] Cross-repo: after Batch 4 merges, update the ScadaBridge coordination note + the
|
||||
umbrella index `../scadaproj/CLAUDE.md` OtOpcUa entry (the design's cutover plan
|
||||
step 4).
|
||||
Reference in New Issue
Block a user