Part 9 ConditionType.Quality was never assigned; default(StatusCode)==Good so every native + scripted condition reported Good unconditionally — a comms-lost device still showed a healthy, inactive, Good condition (a wrong-VALUE bug, distinct from the null-value #473/#475). Clients (and HMIs bucketing on IsGood) could not tell "genuinely inactive" from "lost contact". Layer 1 — make Quality a real, plumbed field: - AlarmConditionSnapshot gains OpcUaQuality Quality (default Good). - MaterialiseAlarmCondition sets it (native BadWaitingForInitialData, scripted Good). - WriteAlarmCondition projects snapshot.Quality; the delta-gate gains a Quality member so a quality-bucket change fires a Part 9 event. Layer 2 — drive native quality from driver connectivity (a comms-lost driver emits no alarm transitions, and an alarm-bearing raw tag has no value variable, so quality can't come from either existing channel): - DriverInstanceActor Tells parent ConnectivityChanged on Connected/Reconnecting. - DriverHostActor fans it to every native condition the driver owns as OpcUaPublishActor.AlarmQualityUpdate (Good on connect, Bad on disconnect). - New dedicated IOpcUaAddressSpaceSink.WriteAlarmQuality sets ONLY Quality and fires only on a bucket change — never touches Active/Acked/Retain (an active alarm that loses comms stays active). Not a full-snapshot re-projection, so it can't clobber severity/message and works for a never-fired condition. Forwarded through DeferredAddressSpaceSink (F10b trap; auto-verified by the reflection forwarding guard). Ungated by redundancy role; no /alerts row. Scripted conditions stay Good; worst-of-input quality deferred to #478 (Layer 3). Tests: node-level (materialise/project/no-clobber/unknown-node no-op), NativeAlarmProjector, DriverInstanceActor connectivity emission, DriverHostActor fan-out, OpcUaPublishActor routing, and the wire-level guard (Condition_event_Quality_tracks_source_connectivity_on_the_wire) — RED-verified against a simulated pre-fix always-Good server. Existing DriverInstanceActor parent probes ignore the new ConnectivityChanged. Docs: docs/AlarmTracking.md §"Condition source-data Quality (#477)"; design doc docs/plans/2026-07-17-alarm-condition-quality-477-design.md.
8.5 KiB
Alarm condition Quality (issue #477) — design
Status: implemented (L1+L2) · Date: 2026-07-17 · Issue: #477 (follow-up chain #473 → #475 → #477) Scope decision: Layer 1 + Layer 2, Bad-direct, annotate-only. Layer 3 (scripted worst-of-input) deferred → #478.
Problem
AlarmConditionState.Quality is never assigned anywhere in src/ — neither by
OtOpcUaNodeManager.MaterialiseAlarmCondition nor by the WriteAlarmCondition transition path.
Because StatusCodes.Good == 0x00000000, default(StatusCode) is Good, so the field is
accidentally valid — clients parse it, but it reports Good unconditionally regardless of the
backing tag's real quality.
This is a wrong-value bug, not the null-value bug class of #473/#475. Part 9 defines
ConditionType.Quality as "the quality of the Condition's source data". OT impact: when a native
alarm's device goes offline (comms lost) the condition still reports Quality = Good, so an operator
(or an HMI bucketing on IsGood) cannot distinguish "genuinely not active" from "we have lost
contact and do not know".
Why it isn't a 2-line default (confirmed by code)
- Alarm-bearing raw tags have no value variable.
AddressSpaceAppliermaterialises a raw tag as either a condition node (tag.Alarm is not null) or a value variable (else) — never both, since they'd share the sames=<RawPath>NodeId. SoWriteValue(the only path carryingOpcUaQuality) is never invoked for an alarm node. Quality has nowhere to land today. - The alarm channel is quality-blind.
AlarmEventArgs(driver → host) andAlarmConditionSnapshot(host → SDK sink) both carry no quality field. - On comms-loss the alarm feed goes silent.
DriverInstanceActoronDisconnectObserveddetaches the alarm subscription and re-entersReconnecting— no transition event ever arrives to carry Bad. So the "device offline" signal must come from driver connectivity, independently of alarm transitions.
Decisions (the issue's open questions)
| # | Question | Decision | Rationale |
|---|---|---|---|
| 1 | Does an alarm tag get quality today? | No | Confirmed above — new plumbing required. |
| 2 | Direct status code vs. policy map | Direct Bad on comms-loss; Good on reconnect | Matches how a value variable would read; unambiguous for IsGood bucketing. |
| 3 | Does Bad also suppress transitions / touch Retain? | No — annotate only | A comms-lost active condition must stay active + retained. Silently clearing an active alarm on comms-loss is the unsafe direction. Quality is a pure annotation; the Active/Ack/Retain state machine is untouched. |
| 4 | Scripted alarms: worst-of-inputs quality? | Deferred (Layer 3) | Scripted conditions stay Good. Filed as a follow-up issue. |
Architecture — reuse the existing publish path, add no sink method
The key move: do not add a new IOpcUaAddressSpaceSink method. A new sink-interface surface would
have to be forwarded through DeferredAddressSpaceSink or it is inert on driver hosts (the F10b
prod-inertness trap). Instead the NativeAlarmProjector becomes the single owner of per-condition
state and quality, and a connectivity change re-projects the last snapshot with a swapped quality
through the existing AlarmStateUpdate → OpcUaPublishActor → WriteAlarmCondition path.
Layer 1 — make Quality a real, plumbed field
AlarmConditionSnapshot(Commons) gainsOpcUaQuality Quality(last positional param, defaultOpcUaQuality.Goodso scripted callers and existing tests keep compiling; Commons already knowsOpcUaQualityviaIOpcUaAddressSpaceSink).MaterialiseAlarmConditionsetsalarm.Quality.Valueat build time: native →BadWaitingForInitialData(honest until connectivity confirms Good, matching the value-variable "waiting for initial data" convention), scripted →Good(script-computed, always live in this scope).WriteAlarmConditionprojectsStatusFromQuality(state.Quality)ontocondition.Quality.Value(+SourceTimestamp).- The delta-gate (
AlarmConditionDelta/ReadConditionDelta/ToConditionDelta) gains aQualitymember, so a Good→Bad bucket change is a genuine delta and fires a Part 9 condition event.
Layer 2 — drive native quality from driver connectivity
DriverInstanceActor: newpublic sealed record ConnectivityChanged(string DriverInstanceId, bool Connected).Context.Parent.Tellit onBecome(Connected)entry (true) and on the transitions intoReconnecting(DisconnectObserved/ForceReconnect) (false). Fire-and-forget, mirrorsDeltaApplied.NativeAlarmProjector: per-node state becomes(bool Active, bool Acked, OpcUaQuality Quality).Project(transition)preserves the current quality; newProjectQuality(nodeId, quality)preserves Active/Acked and swaps only the quality, returning a full snapshot.DriverHostActor:Receive<ConnectivityChanged>iterates_alarmNodeIdByDriverReffor that driver instance and Tells oneAlarmStateUpdateper condition with the re-projected snapshot (connected ? Good : Bad). Ungated — both redundancy nodes track their own driver's comms, matching the existing "condition write stays ungated (Secondary keeps its address space warm)" rule. No/alertsrow for a quality-only change — driver health already has its own status/alerts surface viaIDriverHealthPublisher; a row here would be alarm-fatigue.
Scripted alarms are unaffected: they are not driver instances, receive no ConnectivityChanged, and
their snapshot quality stays Good.
Files
Layer 1
src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/AlarmConditionSnapshot.cssrc/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs(MaterialiseAlarmCondition,WriteAlarmCondition,AlarmConditionDelta/ReadConditionDelta/ToConditionDelta)src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs(ToSnapshot— Quality=Good, or rely on default)
Layer 2
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cssrc/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cssrc/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
Tests (TDD, RED-first)
- Wire-level (the issue's suggested guard) — extend
NativeAlarmEventIdentityFieldDeliveryTests(OpcUaServer.IntegrationTests): active alarm → eventQuality.IsGood; driver disconnect → condition eventQuality.IsGood == false; reconnect → Good. Verify RED against pre-fix. - Node-level —
NodeManagerAlarmSourceFieldsTests: materialise sets Quality (nativeBadWaitingForInitialData, scriptedGood);WriteAlarmConditionprojects snapshot quality and fires on a quality-bucket change only. NativeAlarmProjectorunit:ProjectQualitykeeps Active/Acked + swaps quality;Projectpreserves quality.DriverInstanceActor:Connectedentry TellsConnectivityChanged(true);DisconnectObservedTellsConnectivityChanged(false).DriverHostActor:ConnectivityChanged(false)pushes a Bad-qualityAlarmStateUpdateto every condition of that driver instance.
Deferred / notes
- Layer 3 (scripted worst-of-input quality) → Gitea #478.
- Implementation note: L2 uses a dedicated
IOpcUaAddressSpaceSink.WriteAlarmQualitypath (not a full-snapshot re-projection). Rationale: a connectivity change must set only Quality; re-projecting a full snapshot would clobber a cold condition's severity/message and can't annotate a condition that never fired a transition. The new sink method is forwarded throughDeferredAddressSpaceSink(the F10b inertness trap) — auto-verified byDeferredSinkForwardingReflectionTests(reflection guard) + its realm-discriminator guard. - Test-harness note: the new
DriverInstanceActor → parentConnectivityChangedTell polluted existing parent-TestProbeassertions in 3DriverInstanceActor*Testsfiles; those tests nowparent.IgnoreMessages(m => m is ConnectivityChanged)since they assert on data/alarm/discovery forwards, not connectivity. Bad_NoCommunicationvs genericBad: v1 mapsOpcUaQuality.Bad → StatusCodes.Bad; refiningStatusFromQualityto emitBadNoCommunicationfor the comms-loss case is a one-line nicety, noted in the issue.docs/AlarmTracking.md§"Condition event identity fields" gains a Quality subsection (Good/Bad semantics, annotation-not-state-change, quality-bucket change fires an event).