MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list writes (its element types are only Boolean/Int32/Float/Double/String/ DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a Double list to a Float array. EvaluateEquipmentWriteStructure now converts a numeric array of another numeric element type element-wise, CHECKED, into the node's element type and hands the converted array back through OnEquipmentTagWrite's ref value, so the node stores and the driver receives the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to- integer or Float-overflowing element refuses the whole write BadTypeMismatch (nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays closed. Non-numeric mismatches stay refused. The wrong 'no client relies on a looser rule' comment is corrected. MINOR-1: the array-value-to-scalar-node refusal broke writes back to browse-committed array tags (committed scalar, isArray never set) on pass-through drivers. It now applies only when the owning driver's scalar encoder cannot take an array (DriverArrayWriteSupport. RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan. ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue -> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the classifier whitelist (driver-type edit rebuilds); a mirror whose flag changes is recreated, not updated in place. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
15 KiB
Galaxy Driver
The Galaxy driver bridges OtOpcUa to AVEVA System Platform (Wonderware) Galaxies. It is a Tier-A in-process driver that runs in the OtOpcUa server's .NET 10 AnyCPU process and speaks gRPC to a separately installed mxaccessgw server (sibling repo at c:\Users\dohertj2\Desktop\mxaccessgw\). The gateway owns the MXAccess COM apartment, the STA + Win32 message pump, the Galaxy Repository SQL reader, and the Historian SDK — all the bits that need x86 / .NET Framework 4.8 / COM interop. The driver itself is platform-agnostic and contains no COM, no STA thread, and no x86 bitness constraint.
For the driver spec (capability surface, config shape, addressing), see docs/v2/driver-specs.md §1. For the gateway setup recipe, see docs/v2/Galaxy.ParityRig.md. For tracing, metrics, and soak profile, see docs/v2/Galaxy.Performance.md.
Note
: the related docs
Galaxy-Repository.mdandGalaxy-Test-Fixture.mddescribe the previous v1 / out-of-process topology and now live underdocs/v1/drivers/. For current testing useGalaxy.ParityRig.mdand themxaccessgwrepo.
Architecture
+---------------------------------------+
| OtOpcUa.Server (.NET 10 AnyCPU) |
| GalaxyDriver (in-process) |
| ITagDiscovery / IReadable / |
| IWritable / ISubscribable / |
| IRediscoverable / |
| IHostConnectivityProbe / |
| IAlarmSource |
+-------------------+-------------------+
|
gRPC (default http://localhost:5120)
|
v
+---------------------------------------+
| mxaccessgw (sibling repo) |
| +-------------------------------+ |
| | MxGateway.Worker (x86 net48) | |
| | STA + WM_APP pump | |
| | ArchestrA.MxAccess COM | |
| | Galaxy Repository SQL | |
| | Wonderware Historian SDK | |
| +-------------------------------+ |
+---------------------------------------+
History reads moved server-side in PR 7.2 (IHistoryRouter). Galaxy no longer implements IHistoryProvider of its own.
IAlarmSource was retired with PR 7.2 and restored in PR B.2 of the
alarms-over-gateway epic (docs/plans/alarms-over-gateway.md).
Alarm transitions arrive on the same gateway StreamEvents channel as
data-change events under the new MX_EVENT_FAMILY_ON_ALARM_TRANSITION
family; acknowledgements route through the gateway's
AcknowledgeAlarm RPC. The previous value-driven sub-attribute path
remains as a fallback for Galaxy templates without $Alarm*
extensions — the server-side AlarmConditionService dedups when both
paths fire on the same condition. See docs/AlarmTracking.md
for the v2-final architecture.
Project Layout
The driver ships as a single project: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ (.NET 10, AnyCPU). Sub-folders:
| Folder | Role |
|---|---|
Browse/ |
Static-side discovery: GalaxyDiscoverer walks the gateway's hierarchy + attribute-set RPCs, DataTypeMap and SecurityMap translate Galaxy types and security classifications into OPC UA equivalents, AlarmRefBuilder extracts alarm-bearing attribute references for the server-layer alarm engine. IGalaxyHierarchySource + GatewayGalaxyHierarchySource + TracedGalaxyHierarchySource decorate the gateway browse RPC; IGalaxyDeployWatchSource + GatewayGalaxyDeployWatchSource + DeployWatcher drive IRediscoverable. |
Runtime/ |
Live data path: EventPump runs the gateway's StreamEvents RPC and fans out to subscribers via a bounded channel; GalaxyMxSession is the read-side handle; GatewayGalaxySubscriber + GatewayGalaxyDataWriter (each with a Traced* decorator) implement ISubscribable / IWritable; SubscriptionRegistry tracks subscription state for replay; ReconnectSupervisor owns the backoff loop and triggers ReplaySubscriptions on session loss; StatusCodeMap translates gateway StatusCodes to OPC UA; MxValueDecoder / MxValueEncoder handle scalar + array marshalling; GalaxyTelemetry + GalaxySubscriptionHandle round out the surface. |
Health/ |
HostStatusAggregator rolls per-platform probe state into the driver's IHostConnectivityProbe view; PerPlatformProbeWatcher listens on the gateway's per-host status stream; HostConnectivityForwarder pushes transitions out to the server's connectivity bus. |
Config/ |
GalaxyDriverOptions and the four nested option records (GalaxyGatewayOptions, GalaxyMxAccessOptions, GalaxyRepositoryOptions, GalaxyReconnectOptions). |
Project root files:
GalaxyDriver.cs—IDriver+ capability-interface implementation; composes the Browse / Runtime / Health collaborators.GalaxyDriverFactoryExtensions.cs— DI registration helper used by the server's driver bootstrap.
Capability Surface
GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, IDisposable, IAsyncDisposable.
| Capability | Implementation entry point |
|---|---|
ITagDiscovery |
Browse/GalaxyDiscoverer.cs |
IRediscoverable |
Browse/DeployWatcher.cs — consumed as an operator prompt: a Galaxy redeploy raises a "re-browse" chip on /hosts. ⚠️ It does not rebuild the address space — re-browse the device under /raw and commit (#518) |
IReadable |
Runtime/GalaxyMxSession.cs |
IWritable |
Runtime/GatewayGalaxyDataWriter.cs |
ISubscribable |
Runtime/GatewayGalaxySubscriber.cs (driven by EventPump) |
IHostConnectivityProbe |
Health/HostStatusAggregator.cs |
IAlarmSource |
Runtime/GatewayGalaxyAlarmFeed.cs (transitions) + Runtime/GatewayGalaxyAlarmAcknowledger.cs (acks) |
Configuration
DriverConfig JSON binds to Config/GalaxyDriverOptions.cs. The four sections are:
Gateway— endpoint, API key secret ref, TLS knobs, connect/call/stream timeouts.StreamTimeoutSeconds = 0keeps the long-livedStreamEventsRPC open for the driver's lifetime.MxAccess—ClientName(must be unique per OtOpcUa instance — redundancy pairs enforce uniqueness at install time),PublishingIntervalMs(forwarded asbuffered_update_interval_mson subscribe),WriteUserIdfor ArchestrA secured-write,EventPumpChannelCapacity(default 50_000 — one second of headroom at 50k tags / 1Hz; tune via thegalaxy.events.droppedmetric).Repository—DiscoverPageSize,WatchDeployEvents.Reconnect—InitialBackoffMs,MaxBackoffMs,ReplayOnSessionLost(calls the gateway'sReplaySubscriptionsRPC after reconnect rather than re-issuing subscribe-bulk for every tag).
Full per-field descriptions live in Config/GalaxyDriverOptions.cs. The full JSON skeleton is reproduced in docs/v2/driver-specs.md §1.
Reconnect + Replay
ReconnectSupervisor owns an exponential-backoff loop bounded by Reconnect.InitialBackoffMs / MaxBackoffMs. On session loss it calls GalaxyDriver.ReopenAsync, which invokes GalaxyMxSession.RecreateAsync to dispose the stale/faulted session and client before rebuilding (OpenSessionAsync + RegisterAsync). Previously ConnectAsync was a no-op when a stale session handle was still present, so the reopen supervisor looped forever without recovering. After a successful reopen — when ReplayOnSessionLost = true — the supervisor calls the gateway's ReplaySubscriptions RPC with the cached subscription set from SubscriptionRegistry instead of re-subscribing tag-by-tag. The gateway's worker then re-issues AdviseSupervisory server-side under the apartment lock.
The session-less alarm feed (GatewayGalaxyAlarmFeed) and alarm acknowledger (GatewayGalaxyAlarmAcknowledger) run on a separate always-on _ownedMxClient that is intentionally not recreated when ReconnectSupervisor rebuilds the worker session. The feed has its own re-invoke loop (~5 s backoff) that reopens StreamAlarms after any stream fault, and gRPC.NET's channel auto-reconnect recovers the underlying HTTP/2 connection after a gateway restart — so both the alarm stream and individual ack calls recover without client replacement. The acknowledger is completely stateless between calls: each AcknowledgeAsync issues a single unary RPC and returns; no dead-client state is latched on failure. Channel-level keepalive hardening (TCP keep-alive intervals, gRPC ping frames) would require exposing additional knobs on MxGatewayClientOptions in the sibling mxaccessgw repo — a future option if flaky long-lived connections are observed in production.
Testing
- Unit tests:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/— fakes the gateway gRPC surface; covers Browse, Runtime, Health, and Config in isolation. - Parity rig + dev-rig walkthrough: see docs/v2/Galaxy.ParityRig.md. The rig stands up a real
mxaccessgwagainst a live Galaxy and exercises the full read / write / subscribe / rediscover path. - Performance + soak: see docs/v2/Galaxy.Performance.md.
Browse Tree Shape
GalaxyDiscoverer builds the OPC UA address space as a true nested tree by parent_gobject_id: each gobject folder is parented under its Galaxy parent folder rather than being emitted flat under the driver root. Parentage falls back gracefully — a gobject with parent_gobject_id of 0, a parent not present in the returned set, a self-referencing parent, or a cycle attaches directly to the driver root. This matches the Galaxy ArchestrA object hierarchy as seen in the IDE.
Operational Notes
- MXAccess
ClientNamecollisions: two OtOpcUa instances sharing aClientNamecause the older Wonderware session to lose subscription state. Redundancy pairs (decision #149) enforce uniqueness via install scripts. - Channel saturation:
galaxy.events.dropped > 0indicatesEventPumpis back-pressured. RaiseEventPumpChannelCapacityor investigate downstream slowness in the server-side fan-out. - Connectivity surface: per-platform probe state is exposed through
IHostConnectivityProbeand aggregated by the server's connectivity bus — there is no driver-private dashboard surface anymore. The Admin UI's Host Status panel is the consumer. - A stream the gateway ends cleanly is treated as a fault. A closed or evicted gateway session completes the
StreamEventsRPC without an error, so nothing throws.EventPumpreports that to theReconnectSupervisorexactly as it reports a transport fault. Before this it exited silently: the supervisor was never told,GetHealth()kept reportingHealthy, and because the comms-loss watchdog requires a non-Healthy state to trip, it never fired either — the driver went permanently deaf with no signal anywhere. Nothing else in the system can observe this, so do not remove the notification on the grounds that "a clean end isn't an error". - Replay gaps (
galaxy.events.replay_gaps): the gateway signals that events were evicted from its replay ring before we could read them. The driver responds by re-issuingSubscribeBulkfor every tracked subscription — MXAccess delivers an initial value per item on subscribe, so re-subscribing is the re-snapshot the contract asks for. It does not restart the event pump (the stream carrying the sentinel is healthy) and it honoursReconnect:ReplayOnSessionLost=falseby logging and standing down, on the grounds that an operator who delegated recovery to the gateway did not ask for a re-subscribe on a different trigger. ⚠️ This cannot currently fire: the sentinel is only emitted on a stream opened withafter_worker_sequence > 0, and the driver opens with0, which the gateway treats as "fresh stream, not a resume". Enabling resume is a bigger change than it looks — the sequence is only meaningful within one gateway session, and the reconnect path recreates the session, so a remembered sequence replayed against a fresh session would set the server's live watermark above every event it is about to send, silencing the stream rather than recovering it. - Undecodable values (
galaxy.events.undecodable): a value kind this driver has no CLR representation for is published asBadTypeMismatchwith a null value, and the first occurrence of each distinct kind is logged. It is not published as a null carrying the event's own quality: an MXAccess event can be Good-quality while the payload is one we cannot map, and a good null is indistinguishable from a genuine null reading — a client would consume a value it never received as a real one, with the failure visible only in a log. Non-zero here means the gateway is sending a wire typeMxValueDecoderhas no mapping for and those readings are being lost; the fix is a decoder mapping, not a quality change. - Duplicate
attributeRef(#605 / 06-S-13): two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarmConditionIdsurface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason. - Subscribe-once reads are bounded (#605 / 06-S-3):
ReadAsyncon the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come backBadTimeout, soCancellationToken.Nonecan no longer hang on a silent tag. - Supervisory advise is retried after a thrown failure (#605 / 06-S-12): the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write.
- Array writes (#610): a Galaxy array tag is client-writable only for the
MxValueEncoderarray types (OPC UABoolean/Int32/Int64/Float/Double/String/DateTime) and only with a declaredarrayLength— with none the node is unbounded, and a short whole-array write would resize the attribute. The element type must match the node or be a numeric type that converts to it exactly — a numeric array of another element type is converted, checked, to the node's type in the node manager (anInt32list to anInt64array arrives aslong[]), and any unrepresentable element refuses the writeBadTypeMismatchinline. An array written to a scalar Galaxy node (a browse-committed array attribute withoutisArray) is still dispatched, as before #610. Separately,MxValueEncoderitself refusesuint[]/ulong[]: CLR array covariance let them match theint[]/long[]arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have no live gate yet. See Uns.md §Array writes, including the upgrade note.