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.
16 KiB
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:
- Namespace inversion (root cause).
NamespaceKind(EquipmentvsSystemPlatform) plusDriverTypeRegistry.NamespaceKindCompatibilityandDraftValidator.ValidateDriverNamespaceCompatibilityhard-code thatGalaxyMxGatewaymay live only in aSystemPlatform-kind namespace and every other driver only inEquipment-kind. That single asymmetry forces everything below. - 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/Sourcerow decoration) exists only to compensate for the namespace split. (Shipped 2026-06-12.) - A separate address-space pass.
Phase7Applier.MaterialiseGalaxyTagsruns distinct fromMaterialiseEquipmentTags, with byte-parity exception clauses (|| di.DriverType == "GalaxyMxGateway") inPhase7ComposerandDeploymentArtifact. - Historian gap. Galaxy implements
IAlarmSource(native alarms, verified working end-to-end 2026-05-31) but notIHistoryProvider, and the server has no OPC UAHistoryReaddispatcher 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-authoredTagentities. Wires live read/write/subscribe and scripted alarms (MaterialiseAlarmCondition, keyed byScriptedAlarmId). 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 nativeIAlarmSourcealarms:GenericDriverNodeManager(src/Core/.../OpcUa/GenericDriverNodeManager.cs:52‑83) registers an alarm-condition sink perMarkAsAlarmConditionvariable keyed byDriverAttributeInfo.FullName, subscribesIAlarmSource.OnAlarmEvent, and routes each transition to the sink whose key equalsAlarmEventArgs.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
GalaxyMxGatewayinstance to an Equipment node;DiscoverAsyncauto-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
IHistoryProvidervia 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): removeSystemPlatform(keepEquipment;Simulatedstays reserved).- EF migration (
src/Core/.../Configuration/Migrations/): forward-only. Delete orphanedSystemPlatformnamespace rows, then relax / rebuild theUX_Namespace_Cluster_Kindunique constraint (now effectively single-valued).Namespace.Kindmay remain a column (onlyEquipment) to minimize churn, or be dropped — implementer's call during planning, but the migration is required. DriverTypeRegistry(src/Core/.../Core.Abstractions/DriverTypeRegistry.cs):GalaxyMxGatewayNamespaceKindCompatibility→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.MaterialiseGalaxyTagsand the Galaxy use of theDiscoverAsync/GenericDriverNodeManagermirror.ITagDiscovery.DiscoverAsyncstays for the address-picker live browse only (which already usesIBrowserSessionServiceand is unaffected).
Workstream 2 — Native alarms on the equipment-tag path (the hard part, Phase B)
- Teach
MaterialiseEquipmentTags/OtOpcUaNodeManagerthat for an equipment tag whoseDriverAttributeInfo.IsAlarmis true: register an alarm-condition sink keyed by the tag'sFullName, subscribe the owning driver'sIAlarmSource, and routeOnAlarmEvent.SourceNodeId == FullName→ sink. This portsGenericDriverNodeManager'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
AlarmConditionStatematerialization (OtOpcUaNodeManager.MaterialiseAlarmCondition) and thealarm-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
HistoryReadservice override inOtOpcUaNodeManager(none exists today) → a router that, for anIsHistorizednode, resolves a historian tagname and calls the registered server-sideIHistorianDataSource(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 UAHistorizingattribute +AccessLevel.HistoryReadbit at materialization (OtOpcUaNodeManagerCreateVariable, currently hardcodedHistorizing = false). - Config: a server
Historiansection selecting the backend (Null default; Wonderware when configured) — mirrors the existingAlarmHistorianpattern. - Driver-agnostic by construction: works for any
IsHistorizedtag, 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,LoadGalaxyGatewaysForEquipmentAsyncspecial path),IsAlias/Sourcerow decoration, and the/uns/convert-relayspage +RelayConversionmachinery. - 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:
Phase7ComposerandDeploymentArtifactmust 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: preserveGenericDriverNodeManager's silently-drop behavior (an id may belong to another driver or an unflagged variable). - Historian not configured:
Historianbackend defaults to Null →HistoryReadreturns 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
SystemPlatformrows (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:GalaxyMxGatewaynow valid under Equipment; old SystemPlatform branch gone.Phase7Composer↔DeploymentArtifactbyte-parity with Galaxy equipment tags (no exception clause).- Native-alarm sink routing on the equipment path — fake
IAlarmSourcefiresOnAlarmEvent; assert the equipment tag's condition transitions (Phase B). HistoryReadrouter — fakeIHistorianDataSource; assert Raw / Processed / AtTime mapping + tagname resolution (defaultFullNameand override) (Phase C).IsHistorized→ UAHistorizing+AccessLevel.HistoryReadat 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).
HistoryReada historized Galaxy tag via Client.CLIhistoryread→ samples return (Phase C).
Phasing
- Phase A — de-split + AdminUI fold-in (ws 1 + 4). Galaxy live values + scripted alarms work under Equipment; alias machinery deleted. Shippable alone.
- Phase B — native alarms on the equipment path (ws 2). Restores native Galaxy alarms in the new model.
- Phase C — server-side historian (ws 3). The additive, driver-agnostic
HistoryReadcapability.
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 stagesql_login.txt,src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/, orpending.md. - Never echo the gateway API key or the historian
SharedSecretinto a tracked file. - No force-push, no
--no-verify. - The
NamespaceKindremoval 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.cssrc/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cssrc/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.cssrc/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cssrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cssrc/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.cssrc/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), retireAliasTagModal,RelayConversion*,/uns/convert-relayspage. - Docs:
docs/Uns.md,docs/security.md/driver docs as touched, a newdocs/drivers/Galaxy.mdnote +CLAUDE.md"Alias tags" section rewrite.