91cb907633
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.
286 lines
16 KiB
Markdown
286 lines
16 KiB
Markdown
# 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.
|
||
```
|