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

16 KiB
Raw Blame History

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 pathPhase7ComposerDeploymentArtifactOtOpcUaNodeManager.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 pathITagDiscovery.DiscoverAsyncGenericDriverNodeManager → 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 NamespaceKindCompatibilityEquipment.
  • 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.
  • Phase7ComposerDeploymentArtifact 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.