docs(galaxy): design — Galaxy as a standard Equipment driver

Brainstorming-approved design to normalize GalaxyMxGateway into the standard
Equipment-driver model: retire the SystemPlatform/Equipment namespace split +
the SystemPlatform mirror + the alias-tag/relay machinery, author Galaxy points
as ordinary equipment tags, port native IAlarmSource alarms onto the
equipment-tag materialization path, and add a driver-agnostic server-side
HistoryRead backend (over the existing Wonderware Historian reader). Three
phases (A de-split + UI, B native alarms, C historian); clean break, no
migration converter; one EF migration to drop NamespaceKind.
This commit is contained in:
Joseph Doherty
2026-06-12 20:55:08 -04:00
parent db2e4777dd
commit 91cb907633
@@ -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:5283`)
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`** (~`:225245`): 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.
```