Files
lmxopcua/docs/plans/2026-06-12-galaxy-standard-driver-design.md
T
Joseph Doherty 91cb907633 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.
2026-06-12 20:55:08 -04:00

286 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```