diff --git a/docs/plans/2026-06-12-galaxy-standard-driver-design.md b/docs/plans/2026-06-12-galaxy-standard-driver-design.md new file mode 100644 index 00000000..e31a55d8 --- /dev/null +++ b/docs/plans/2026-06-12-galaxy-standard-driver-design.md @@ -0,0 +1,285 @@ +# Galaxy as a Standard Equipment Driver — Design + +**Date:** 2026-06-12 +**Status:** Approved (brainstorming) — ready for implementation planning +**Scope:** One design, three phases (A → B → C). Each phase is an independently +mergeable, live-verifiable branch. + +## Goal + +Make `GalaxyMxGateway` **just another standard driver**: bound under +**Equipment**-kind namespaces like Modbus/S7, authored through the normal +`TagModal`, materialized through the single equipment-tag path, with **native +alarms** and **server-side historian / `HistoryRead`** working in that model. +Retire the `SystemPlatform` namespace split, the SystemPlatform mirror, and the +alias-tag/relay machinery. + +## Background — why Galaxy is "treated differently" today + +The `GalaxyMxGateway` driver already implements the full Tier-A capability set +through the same `IDriver`/`IDriverFactory` path as Modbus +(`IReadable`/`IWritable`/`ISubscribable`/`ITagDiscovery`), plus more +(`IAlarmSource` native alarms, `IRediscoverable` deploy-watch). It is **not** the +odd one out at the driver-contract level. The difference lives **above the +socket**, in four seams: + +1. **Namespace inversion (root cause).** `NamespaceKind` (`Equipment` vs + `SystemPlatform`) plus `DriverTypeRegistry.NamespaceKindCompatibility` and + `DraftValidator.ValidateDriverNamespaceCompatibility` hard-code that + `GalaxyMxGateway` may live **only** in a `SystemPlatform`-kind namespace and + every other driver **only** in `Equipment`-kind. That single asymmetry forces + everything below. +2. **The alias-tag / relay workaround.** Because Galaxy can't live under + Equipment directly, its objects are bridged into the UNS via *alias tags* + (`Tag{EquipmentId, DriverInstanceId=GalaxyMxGateway, TagConfig={"FullName":…}}`) + and the relay→alias converter. A whole machinery + (`CreateAliasTagAsync`, `CheckAliasDriverGuardAsync`, `AliasTagModal`, + `/uns/convert-relays`, `IsAlias`/`Source` row decoration) exists **only** to + compensate for the namespace split. (Shipped 2026-06-12.) +3. **A separate address-space pass.** `Phase7Applier.MaterialiseGalaxyTags` runs + distinct from `MaterialiseEquipmentTags`, with byte-parity exception clauses + (`|| di.DriverType == "GalaxyMxGateway"`) in `Phase7Composer` and + `DeploymentArtifact`. +4. **Historian gap.** Galaxy implements `IAlarmSource` (native alarms, verified + working end-to-end 2026-05-31) but **not** `IHistoryProvider`, and the server + has **no OPC UA `HistoryRead` dispatcher at all** today. + +### The decisive finding: two materialization paths + +The server has two ways nodes reach the address space, and Galaxy and Modbus use +*different* ones: + +- **Authored-equipment-tag path** — `Phase7Composer` → `DeploymentArtifact` → + `OtOpcUaNodeManager.MaterialiseEquipmentTags`. Materializes explicitly-authored + `Tag` entities. Wires live read/write/subscribe **and scripted alarms** + (`MaterialiseAlarmCondition`, keyed by `ScriptedAlarmId`). Alias tags and + Modbus tags live here. +- **Driver-discovery path** — `ITagDiscovery.DiscoverAsync` → + `GenericDriverNodeManager` → builder. Walks the driver's whole tree and is the + **only** path that wires **native `IAlarmSource` alarms**: + `GenericDriverNodeManager` (`src/Core/.../OpcUa/GenericDriverNodeManager.cs:52‑83`) + registers an alarm-condition sink per `MarkAsAlarmCondition` variable keyed by + `DriverAttributeInfo.FullName`, subscribes `IAlarmSource.OnAlarmEvent`, and + routes each transition to the sink whose key equals `AlarmEventArgs.SourceNodeId`. + **This path is the SystemPlatform mirror.** + +Consequence: Galaxy **live values already work on equipment tags** (alias tags +prove it — direct driver subscription by `FullName`), but Galaxy **native alarms +only work on the mirror**. **Killing the mirror means the equipment-tag path must +gain native-alarm wiring.** That is the single largest real piece of work in this +design (Phase B). + +## Locked decisions (from brainstorming) + +| Decision | Choice | +|---|---| +| Binding model | **Authored equipment tags** — a Galaxy point is an ordinary `Tag` bound to `GalaxyMxGateway` with `TagConfig.FullName`. No alias concept. | +| Historian | **Server-side, driver-agnostic** — an `IsHistorized` tag's `HistoryRead` is served by the server's configured historian backend (the existing Wonderware sidecar reader), mapping the tag → historian tagname. No mxaccessgw history RPC. | +| Migration | **Clean break** — remove `SystemPlatform`; no converter; reseed docker-dev rigs by hand. Schema/EF change to drop the split is in scope. | +| Alias machinery | **Delete it** — Galaxy authors through the standard `TagModal`; `AliasTagModal`, alias guards, `/uns/convert-relays`, `IsAlias`/`Source` decoration all retire. | +| Doc/plan shape | **One design, three phases.** | + +### Approaches considered and rejected + +- **Discovery-into-equipment** (bind a `GalaxyMxGateway` instance to an Equipment + node; `DiscoverAsync` auto-materializes the subtree). Rejected: reworks how + Equipment materialization and authored-vs-discovered tags coexist; larger and + changes the data model. +- **Keep the SystemPlatform mirror as browse-only.** Rejected: leaves two + materialization paths — only partially meets the goal. +- **Galaxy driver implements `IHistoryProvider` via mxaccessgw.** Rejected: + requires new history RPCs in the sibling gateway repo (cross-repo, out of this + repo's control) and is not driver-agnostic. +- **One-time migration converter** / **keep SystemPlatform deprecated.** Rejected + in favor of the clean break — this is pre-release with only dev rigs. + +## Architecture (target end-state) + +`GalaxyMxGateway` is an ordinary **Equipment-kind** driver. A Galaxy point is an +authored `Tag{EquipmentId set, DriverInstanceId=GalaxyMxGateway, +TagConfig={"FullName":"DelmiaReceiver_001.DownloadPath"}}` — identical to today's +alias tag with the "alias" concept, guards, and namespace restriction deleted. +The `NamespaceKind` split, the SystemPlatform mirror, `MaterialiseGalaxyTags`, and +the byte-parity `|| di.DriverType == "GalaxyMxGateway"` clauses retire. History +for **any** `IsHistorized` tag (not just Galaxy) is served by a new server-side +history backend wrapping the existing Wonderware Historian client. + +## Components / workstreams + +### Workstream 1 — Namespace de-split (foundation, Phase A) + +- **`NamespaceKind`** (`src/Core/.../Configuration/Enums/NamespaceKind.cs`): remove + `SystemPlatform` (keep `Equipment`; `Simulated` stays reserved). +- **EF migration** (`src/Core/.../Configuration/Migrations/`): forward-only. + Delete orphaned `SystemPlatform` namespace rows, then relax / rebuild the + `UX_Namespace_Cluster_Kind` unique constraint (now effectively single-valued). + `Namespace.Kind` may remain a column (only `Equipment`) to minimize churn, or be + dropped — implementer's call during planning, but the migration is required. +- **`DriverTypeRegistry`** (`src/Core/.../Core.Abstractions/DriverTypeRegistry.cs`): + `GalaxyMxGateway` `NamespaceKindCompatibility` → `Equipment`. +- **`DraftValidator.ValidateDriverNamespaceCompatibility`** (~`:225‑245`): drop the + Galaxy/SystemPlatform branches. +- **`Phase7Composer`** + **`DeploymentArtifact.BuildEquipmentTagPlans`**: remove the + `|| di.DriverType == "GalaxyMxGateway"` exception clauses — the equipment-tag + filter now admits Galaxy naturally. **Must stay byte-parity with each other.** +- **Retire `Phase7Applier.MaterialiseGalaxyTags`** and the Galaxy use of the + `DiscoverAsync`/`GenericDriverNodeManager` mirror. `ITagDiscovery.DiscoverAsync` + **stays** for the address-picker live browse only (which already uses + `IBrowserSessionService` and is unaffected). + +### Workstream 2 — Native alarms on the equipment-tag path (the hard part, Phase B) + +- Teach `MaterialiseEquipmentTags` / `OtOpcUaNodeManager` that for an equipment tag + whose `DriverAttributeInfo.IsAlarm` is true: register an alarm-condition sink + keyed by the tag's `FullName`, subscribe the owning driver's `IAlarmSource`, and + route `OnAlarmEvent.SourceNodeId == FullName` → sink. This ports + `GenericDriverNodeManager`'s alarm-forwarder logic into the curated path + (consider extracting the forwarder so both paths share it rather than + duplicating). +- **Reuse** the existing Part 9 `AlarmConditionState` materialization + (`OtOpcUaNodeManager.MaterialiseAlarmCondition`) and the `alarm-commands` / ack + plumbing — those are condition-node mechanics and are agnostic to whether the + alarm is scripted or native. +- Teardown / rediscovery parity: mirror `GenericDriverNodeManager`'s + unsubscribe-before-rewalk so a Galaxy redeploy (`IRediscoverable`) does not + double-deliver. + +### Workstream 3 — Server-side historian backend (additive, Phase C) + +- New OPC UA **`HistoryRead` service override** in `OtOpcUaNodeManager` (none exists + today) → a **router** that, for an `IsHistorized` node, resolves a historian + tagname and calls the registered server-side `IHistorianDataSource` (the + Wonderware backend hardened in the TCP-transport work). +- **Tagname mapping:** default to the tag's driver `FullName`, with an optional + explicit override on the tag. (System Platform logs attributes to AVEVA + Historian under the same reference, so the default is usually correct.) +- Apply `DriverAttributeInfo.IsHistorized` → set the UA `Historizing` attribute + + `AccessLevel.HistoryRead` bit at materialization + (`OtOpcUaNodeManager` `CreateVariable`, currently hardcoded `Historizing = false`). +- **Config:** a server `Historian` section selecting the backend (Null default; + Wonderware when configured) — mirrors the existing `AlarmHistorian` pattern. +- Driver-agnostic by construction: works for any `IsHistorized` tag, not only + Galaxy. + +### Workstream 4 — AdminUI fold-in (Phase A) + +- Galaxy tag authored through the **standard `TagModal`** + the Galaxy live-browse + picker (`GalaxyAddressPickerBody`) as that driver's address picker — same + convention as Modbus/S7 pickers. +- **Retire** `AliasTagModal`, the alias guards (`CheckAliasDriverGuardAsync`, + `LoadGalaxyGatewaysForEquipmentAsync` special path), `IsAlias`/`Source` row + decoration, and the `/uns/convert-relays` page + `RelayConversion` machinery. +- **Keep** `GalaxyDriverPage` (typed driver-config editor) — that is the + convention other drivers are moving toward, not a special-case. +- Add a driver-agnostic **`IsHistorized`** (+ optional historian-tagname) control + to the tag editor (Phase C surfaces it; the control can land in Phase A inert). + +## Data flow (a Galaxy tag, after) + +``` +Author Tag{Equipment, GalaxyMxGateway, FullName} via TagModal + Galaxy picker + → DraftValidator (no Galaxy special branch) + → Phase7Composer / DeploymentArtifact (byte-parity, no exception clause) + → OtOpcUaNodeManager.MaterialiseEquipmentTags + • live value : driver ISubscribable/IReadable by FullName (works today) + • native alarm : IsAlarm → register sink + IAlarmSource.OnAlarmEvent (NEW, ws-2) + • historized : IsHistorized → Historizing + HistoryRead bit (NEW, ws-3) +OPC UA client HistoryRead + → OtOpcUaNodeManager.HistoryRead override (NEW, ws-3) + → server historian backend (IHistorianDataSource = Wonderware sidecar) + → AVEVA Historian +``` + +## Error handling / edge cases + +- **Byte-parity invariant:** `Phase7Composer` and `DeploymentArtifact` must produce + identical equipment-tag plans for the same input after the exception clauses are + removed. Covered by a parity round-trip test. +- **Unknown alarm `SourceNodeId`:** preserve `GenericDriverNodeManager`'s + silently-drop behavior (an id may belong to another driver or an unflagged + variable). +- **Historian not configured:** `Historian` backend defaults to Null → + `HistoryRead` returns the standard "history unsupported / no data" status, not an + error spike. +- **Redeploy double-delivery:** unsubscribe the native-alarm forwarder before + re-walking on `IRediscoverable.OnRediscoveryNeeded`. +- **Migration on a non-empty DB:** the forward-only EF migration must tolerate + existing `SystemPlatform` rows (delete them) before the relaxed constraint + applies. + +## Migration (clean break) + +No converter. Drop `SystemPlatform`; existing SystemPlatform namespaces / mirror +tags are abandoned and the docker-dev rigs are **reseeded by hand** with Galaxy +tags authored under Equipment. The EF migration is forward-only and includes a +data step to delete orphaned `SystemPlatform` namespace rows so the relaxed +constraint applies cleanly. + +## Testing (no bUnit) + +**xUnit + Shouldly (offline):** +- `DraftValidator`: `GalaxyMxGateway` now valid under Equipment; old SystemPlatform + branch gone. +- `Phase7Composer` ↔ `DeploymentArtifact` byte-parity with Galaxy equipment tags + (no exception clause). +- **Native-alarm sink routing on the equipment path** — fake `IAlarmSource` fires + `OnAlarmEvent`; assert the equipment tag's condition transitions (Phase B). +- **`HistoryRead` router** — fake `IHistorianDataSource`; assert Raw / Processed / + AtTime mapping + tagname resolution (default `FullName` and override) (Phase C). +- `IsHistorized` → UA `Historizing` + `AccessLevel.HistoryRead` at materialization + (Phase C). + +**Live docker-dev `/run` (user-driven; the agent does NOT sign in)** — the gate, +given this codebase's documented live-only Razor binding history: +- Author a Galaxy equipment tag via `TagModal` → live value updates. +- Trip a Galaxy alarm → Part 9 condition appears under the equipment + ack + round-trips (Phase B). +- `HistoryRead` a historized Galaxy tag via Client.CLI `historyread` → samples + return (Phase C). + +## Phasing + +1. **Phase A — de-split + AdminUI fold-in** (ws 1 + 4). Galaxy live values + + scripted alarms work under Equipment; alias machinery deleted. Shippable alone. +2. **Phase B — native alarms on the equipment path** (ws 2). Restores native + Galaxy alarms in the new model. +3. **Phase C — server-side historian** (ws 3). The additive, driver-agnostic + `HistoryRead` capability. + +Each phase is a mergeable feature branch off master with its own live-verify. +Phase C is the most isolated and largest; if it grows it can split into its own +plan. + +## Hard rules (carried into implementation) + +- Stage by path; never `git add .`. Never stage `sql_login.txt`, + `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, or `pending.md`. +- Never echo the gateway API key or the historian `SharedSecret` into a tracked + file. +- No force-push, no `--no-verify`. +- The `NamespaceKind` removal **does** require a Configuration/EF migration — that + is in scope for this design (a deliberate exception to the usual "no migration" + rule). +- Build each phase on a feature branch off master. + +## Authoritative touched-code list (for planning) + +- `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs` +- `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs` +- `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs` (constraint) +- `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/*` (new migration) +- `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs` +- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs` +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs` +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` (retire MaterialiseGalaxyTags) +- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (native-alarm wiring, IsHistorized attrs, HistoryRead override) +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` +- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` (extract shared forwarder) +- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs` (server-side history router seam) +- `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware*` (register as server historian backend) +- AdminUI: `Uns/` (`UnsTreeService`, `IUnsTreeService`, alias removal), `Uns/TagEditors/*`, + `Components/Shared/Drivers/Pickers/GalaxyAddressPickerBody.razor` (keep), + retire `AliasTagModal`, `RelayConversion*`, `/uns/convert-relays` page. +- Docs: `docs/Uns.md`, `docs/security.md`/driver docs as touched, a new + `docs/drivers/Galaxy.md` note + `CLAUDE.md` "Alias tags" section rewrite. +```