feat(alarms): bridge driver comms-loss into scripted-alarm input quality (Layer 4 of #477) #481
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Layer 4 of the condition-Quality chain (#477 → #478). #478 shipped scripted-alarm
Qualityderived from the worst of its input tags' qualities, but only for inputs whose driver publishes a data change carrying a Bad/UncertainStatusCode(e.g. an OpcUaClient input forwarding a server's per-item Bad).It does not cover a driver comms loss: a poll driver (Modbus/S7) whose device goes unreachable emits only
DriverInstanceActor.ConnectivityChanged(false)and goes silent on the value feed — it does not republish its tags as Bad. So the scripted engine keeps the last-known Good value,WorstInputStatusstays Good, and:Qualitystays Good (opposite of the "can I trust this condition's state?" intent), andAreInputsReadystays true (last value is Good), so the predicate keeps evaluating a stale value after the source died.Native alarms already solve this:
DriverHostActor.OnDriverConnectivityChangedfans anAlarmQualityUpdate(Bad)to every native condition the driver owns (_alarmNodeIdByDriverRef). Scripted alarms need the symmetric bridge into their inputs.The ask
Bridge driver connectivity into scripted-alarm input quality:
ConnectivityChanged(false), mark every mux ref the driver publishes asBadfor the scripted engine (restored to real quality on reconnect, which self-heals viaResubscribeDesiredrepublishing values).DriverHostActorcurrently tracks driver → native alarm nodes (_alarmNodeIdByDriverRef) but not driver → all tag refs; that per-driver ref set needs to exist to fan connectivity into the mux.WorstInputStatusskips a null-value input as "no data yet" (contributes Good) to avoid a deploy-time Bad flash, but that makes a genuine runtimeBadwith a null value indistinguishable from cold start. Distinguish "never pushed" (cold → Good) from "pushed Bad" (→ Bad), e.g. by tracking pushed-ness inDependencyMuxTagUpstreamSource.Notes / considerations
Bad— arguably correct, but a behavior change to weigh (VT already has its own Bad-propagation path; confirm no double-handling).Badon the wire; unpause →Good. (This is the same live-gate style the #477 native path used.)References
OnDriverConnectivityChangedbridge.docs/AlarmTracking.md§"Scripted alarms (Layer 3, #478)" "Coverage boundary" anddocs/plans/2026-07-17-alarm-condition-quality-477-design.md.Implemented — master
c81cef39(merged9fa701b1). Closing the code work; the live gate is carried by #527, filed from this gate — read the last section before relying on this path.DriverHostActor.FanCommsLossToDependencyMuxpushes a syntheticBadNoCommunicationwith a null value into the dependency mux for every value ref a just-disconnected driver owns.The ref set already existed. This issue asked for a driver → all-tag-refs map to be built;
_nodeIdByDriverRefis exactly that, keyed(DriverInstanceId, RawPath)and rebuilt from the composition on every apply. Alarm-bearing tags live in the separate_alarmNodeIdByDriverRefand are deliberately excluded — a native condition is not a mux value and takes the Part 9 quality path instead.Nothing is pushed on reconnect, deliberately — publishing Good would assert a value quality the host has not observed. Recovery rides the driver's own republish, and I verified the mechanism rather than assuming it: the
Connected → Reconnectingedge callsDetachSubscription, which nulls the subscription handle, so the reconnect'sResubscribeDesiredbuilds a fresh subscription whosePollGroupEngineinitial push runs withforceRaise: true. Without that detach the poll engine would compare against its retainedLastValues, see an unchanged value, raise nothing, and strand these refs Bad forever — the value-dedup trap that bit the v3 Batch 4 virtual-tag reassert.Finding 3 is closed, and the fan-out depends on it.
WorstInputStatusskipped every input whose value was null as "no data yet" — and Bad-with-no-value is exactly the shape a comms-lost driver produces, so the skip was hiding the case that matters most. Without this half the fan-out above would have been silently inert. The upstream source now stamps a never-pushed pathBadWaitingForInitialData(the honest Part 4 code) and the engine skips that specific code rather than every null.Deleting the null-skip outright is the obvious fix and is wrong in the other direction: it makes every not-yet-published input drag its condition Bad at deploy, which is what the skip existed to prevent. Both mistakes are pinned by a test pair that goes red under the opposite error. Falsified by reverting the production code:
The never-pushed test was vacuous when first written and is worth recording: the quality bucket only emits on change, so the load-time evaluation is the one that flashes Bad, and subscribing to
OnEventafterLoadAsyncmade the assertion unfalsifiable — it passed against the broken behaviour. It now subscribes first. Relatedly,FakeUpstream.ReadTagreturned0x80340000for an unset path, a different Bad code from production's placeholder; harmless while any Bad behaved alike, load-bearing now, so it is aligned and pinned by a reflection guard asserting the engine's private constant agrees with what the production source stamps.Behaviour change you flagged, confirmed: virtual tags computed from a comms-lost input now go Bad too. That follows from using the shared mux and reads as correct, but it is broader than scripted alarms. No double-handling was found.
Live gate: could not close on the headline scenario — #527
The docker-dev gate this issue specifies (Modbus-fed scripted alarm, pause the fixture, assert Bad on the wire) did not close, for a reason worth knowing: this issue's premise is wrong.
It emits no such thing.
DisconnectObservedhas no production sender anywhere insrc/— the only paths intoReconnectingare a failed initial connect and an operator Force Reconnect. Live on the rig: with the Modbus fixture stopped, the driver stayedConnectedfor over ten minutes publishing per-tag Bad, and did not recover when the fixture came back until the node was restarted. Filed with the full evidence as #527.So this fan-out is correctly wired to a signal that a dying device never raises. It does fire on the two paths that exist today (failed initial connect, admin Force Reconnect), and it is the right wiring for when #527 lands — whose acceptance criteria include exactly the Quality-on-the-wire check this gate wanted.
What the gate did establish: the change deploys and runs (scripted alarm materialised on central-1 against the seeded Modbus tag through a real
POST /api/deployments), and #478 Layer 3 does carry Bad into scripted input quality for Modbus, because that driver publishes Bad per tag rather than going silent. That is a per-driver accident, not a guarantee.One incidental gap: Client.CLI cannot observe condition
Quality— its alarm event filter has no select clause for it, andreadthrows on the node's Bad DataValue status before showing the value. Three consecutive issues (#477, #478, this one) have turned on Quality, so adding it to the CLI filter would pay for itself; noted rather than done here.