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:
@@ -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.
|
||||
```
|
||||
Reference in New Issue
Block a user