Second PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Depends on PR A.1 in mxaccessgw
(merged) which added the OnAlarmTransitionEvent body + family. No
runtime impact yet — the gateway doesn't emit the new family until
A.3 ships; this PR just stops dropping it on the floor.
EventPump.Dispatch becomes a switch on MxEventFamily. The new
DispatchAlarmTransition decodes the proto event, runs the raw severity
through MxAccessSeverityMapper (the same four-bucket ladder v1 used —
250/500/750/1000 boundaries per docs/v1/AlarmTracking.md), and fires
an internal OnAlarmTransition event with a GalaxyAlarmTransition
record carrying the full payload.
Body absent or transition-kind unspecified → counted via
galaxy.alarm_transitions.decoding_failures and dropped. Gateway
version skew or worker malformed event therefore degrades to "fall
back to the sub-attribute path" rather than crashing the pump.
GalaxyDriver consumes the internal event in PR B.2 (next), wrapping
it onto IAlarmSource.OnAlarmEvent. The richer fields (operator user
+ comment, original raise time, category) become visible on the OPC
UA Part 9 condition once AlarmEventArgs gets extended in E.7.
Tests:
- MxAccessSeverityMapperTests — full bucket ladder + clamp behaviour
for negative + out-of-range inputs.
- EventPumpAlarmTests — raise/ack/clear sequence dispatches in order
with operator metadata + original-raise preserved; unspecified
kind drops; missing body drops; mixed data-change + alarm streams
dispatch independently; OnWriteComplete / OperationComplete
filtered out.
Full Driver.Galaxy.Tests suite: 196 passed (was 191 — 5 new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouples the gw stream-read loop from the listener-fanout loop with a
bounded Channel<MxEvent> (default capacity 50_000) sitting between them.
When a slow listener fills the channel, the producer's TryWrite returns
false and we count the drop rather than back-pressuring the gw stream.
Three counters on the ZB.MOM.WW.OtOpcUa.Driver.Galaxy meter expose the
pressure curve before it manifests as user-visible loss:
- galaxy.events.received — MxEvents read from StreamEvents
- galaxy.events.dispatched — MxEvents that made it through to OnDataChange
- galaxy.events.dropped — MxEvents discarded because the channel was full
Each measurement carries a galaxy.client tag so multi-driver hosts can
split by source. The driver wires _options.MxAccess.ClientName into the
new EventPump constructor parameter.
Tests: drop-newest under pressure, capacity validation, and per-pump
measurement filtering (xUnit can run other pump tests in parallel and
their measurements land on the same listener — the test filters to its
own client name).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subscription path online. GalaxyDriver implements ISubscribable; subscribes
batches via gw SubscribeBulkAsync, runs a single shared EventPump consumer
of StreamEventsAsync, fans out OnDataChange events to every driver
subscription that observes the changed gw item handle.
Files:
- Runtime/GalaxySubscriptionHandle.cs — record implementing ISubscriptionHandle.
- Runtime/SubscriptionRegistry.cs — bookkeeping with forward (subscriptionId
→ bindings) and reverse (itemHandle → list of subscriptionIds) maps. The
reverse map is the fan-out index so a single OnDataChange dispatches to
every subscription that observes the changed handle.
- Runtime/IGalaxySubscriber.cs — driver-side seam: SubscribeBulk +
UnsubscribeBulk + StreamEventsAsync. Production wraps GalaxyMxSession;
tests substitute a fake driving synthetic MxEvents.
- Runtime/GatewayGalaxySubscriber.cs — production. Forwards to
MxGatewaySession; bufferedUpdateIntervalMs is captured for now and
becomes a SetBufferedUpdateInterval call once gw issue #102 / gw-9 lands
(PR 6.3 picks this up).
- Runtime/EventPump.cs — long-running background consumer of
StreamEventsAsync. Decodes MxValue + maps quality byte/MxStatusProxy via
StatusCodeMap. Fan-out per subscriber resolves through the registry; bad
handler exceptions are caught + logged, never break the dispatch loop.
Filters out non-OnDataChange families (write-complete and operation-
complete come back via InvokeAsync's reply path, not the event stream).
GalaxyDriver:
- Adds ISubscribable. SubscribeAsync allocates a subscription id,
SubscribeBulks, builds the binding list (failed gw entries get
ItemHandle=0 + a per-tag warn log), registers, and returns the handle.
EventPump is started lazily on first subscribe; one pump per driver
shared across all subscriptions.
- UnsubscribeAsync removes from the registry first (so stale events are
filtered immediately) then calls UnsubscribeBulk best-effort. Foreign
handles throw ArgumentException.
- ReadAsync NotSupportedException message updated: PR 4.4 no longer the
pointer (deferred to a small follow-up that wraps the pump as a
one-shot reader).
- Dispose tears down the pump first, then the repository client, then
clears state.
- Internal ctor extended with optional subscriber parameter.
Tests (15 new, 109 Galaxy total):
- SubscriptionRegistryTests: monotonic id allocation, single+multi
subscription fan-out, failed-handle exclusion, removal isolation, count
invariants.
- GalaxyDriverSubscribeTests: handle allocation + value-change dispatch,
multi-subscription fan-out, failed-tag silence, unsubscribe drops gw
handle and stops dispatch, foreign handle throws, no-subscriber throws,
empty-tag-list returns handle without calling gw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>