docs(v3): implementation plans — master orchestration + 4 batch plans
- v3 design finalized: all 7 open items resolved (live historian probe:
255-char tagname limit; SDK multi-notifier spike: native AddNotifier,
never duplicate ReportEvent; NamespaceKind.Simulated retired; CSV column
dictionaries; ScadaBridge re-bind sized; rename-warning scan decided),
reconciled with the pre-v3 universal discovery browser (its new §11
v3 forward note re-targets the browse commit contract).
- Calculation pseudo-driver mini-design (IDependencyConsumer capability,
mux-fed change/timer triggers, Tarjan cycle deploy gate, VT-parity
error semantics).
- Implementation plans for Opus-agent execution, grounded against exact
files/symbols: master plan (batch DAG, worktree-isolated parallel
waves, contract-first fan-out, global gotcha list) + one plan per
batch (1 schema+RawPath identity, 2 /raw UI+Calculation, 3 UNS
reference-only+{{equip}}, 4 dual-namespace address space = v3.0),
each with per-wave file ownership and a mandatory docker-dev live gate.
This commit is contained in:
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user