feat(alarms): bridge driver comms-loss into scripted-alarm input quality (#481)
Layer 4 of the condition-Quality chain (#477 -> #478). #478 gave a scripted condition the worst quality of its input tags, but only for inputs whose driver PUBLISHES a Bad data change. A poll driver whose device goes unreachable publishes nothing — it raises ConnectivityChanged(false) and goes silent — so the engine held its last Good value, WorstInputStatus stayed Good, and worse, AreInputsReady stayed true, so the predicate went on evaluating a stale value after the source had died. `DriverHostActor.FanCommsLossToDependencyMux` pushes a synthetic BadNoCommunication with a null value into the dependency mux for every value ref the disconnected driver owns. The issue asked for a driver -> all-tag-refs map to be built for this. It already exists: `_nodeIdByDriverRef`, keyed (DriverInstanceId, RawPath) and rebuilt from the composition on every apply. Alarm-bearing tags live in the separate `_alarmNodeIdByDriverRef` and 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 this actor has not observed. Recovery rides the driver's own republish, and the mechanism was verified rather than assumed — the Connected -> Reconnecting edge calls DetachSubscription, which nulls the subscription handle, so the reconnect's ResubscribeDesired builds a FRESH subscription whose PollGroupEngine initial push runs with forceRaise: true. Without that detach the poll engine would compare against its retained LastValues, 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. Closes #478 Finding 3, which the above depends on. WorstInputStatus skipped 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 be silently inert. The upstream source now stamps a never-pushed path BadWaitingForInitialData — the honest Part 4 code for that state — and the engine skips that specific code rather than every null value. 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, flooding the quality path with load-time annotations, which is what the skip existed to prevent. Both mistakes are pinned by a test pair that goes red under the opposite error, and both were falsified by reverting the production code: old null-skip restored -> A_pushed_Bad_carrying_no_value RED null-skip deleted outright -> An_input_that_was_never_pushed RED fan-out call removed -> the three DriverHostActor tests RED (1 of 7) The never-pushed test was vacuous on first writing 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 OnEvent after LoadAsync made the assertion unfalsifiable — it passed against the broken behaviour. It now subscribes first. `FakeUpstream.ReadTag` returned 0x80340000 (BadNotConnected) for 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 to weigh, flagged in the issue and 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. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -32,6 +32,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarmEngine : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// OPC UA Part 4 <c>BadWaitingForInitialData</c> — the status the upstream source stamps on a
|
||||
/// path nothing has ever pushed to, and the marker <see cref="WorstInputStatus"/> uses to tell
|
||||
/// genuine cold start apart from a driver reporting Bad.
|
||||
/// <para>Inlined rather than taken from <c>Commons.OpcUa.OpcUaStatus</c> because this project
|
||||
/// deliberately does not reference <c>Commons</c> — the engine is pure evaluation logic over
|
||||
/// <c>Core.Abstractions</c>. The value is fixed by the OPC UA spec, so the duplication cannot
|
||||
/// drift; <c>DependencyMuxTagUpstreamSourceTests</c> pins the two agreeing.</para>
|
||||
/// </summary>
|
||||
private const uint StatusNeverPushed = 0x80320000u;
|
||||
|
||||
private readonly ITagUpstreamSource _upstream;
|
||||
private readonly IAlarmStateStore _store;
|
||||
private readonly ScriptLoggerFactory _loggerFactory;
|
||||
@@ -736,19 +747,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
WorstInputStatusCode: worstInputStatus);
|
||||
|
||||
/// <summary>Worst OPC UA StatusCode across a refilled read cache — the entry with the highest severity
|
||||
/// bits (top 2). An input with no value yet (null snapshot/value — the cold-start placeholder, or a
|
||||
/// not-yet-published upstream) is NOT a quality signal: it means "no data", which the
|
||||
/// <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it as Bad here
|
||||
/// would flash every scripted condition Bad at deploy until the first push and would flood the quality
|
||||
/// path with load-time annotations, so unread inputs are skipped (contribute Good). Empty / all-unread
|
||||
/// cache ⇒ Good (0).</summary>
|
||||
/// bits (top 2). An input that has never been pushed is NOT a quality signal: it means "no data", which
|
||||
/// the <see cref="AreInputsReady"/> guard already handles by holding the condition. Counting it would
|
||||
/// flash every scripted condition Bad at deploy until the first push and flood the quality path with
|
||||
/// load-time annotations, so it is skipped (contributes Good). Empty / all-unpushed cache ⇒ Good (0).
|
||||
/// <para><b>"Never pushed" is a specific status code, not "the value is null" (#481, closing #478
|
||||
/// Finding 3).</b> This used to skip every entry whose value was null, which made a genuine runtime Bad
|
||||
/// indistinguishable from cold start — and that is exactly the shape a comms-lost driver produces, so
|
||||
/// the case the skip was hiding was the case that matters most. The upstream source now stamps a
|
||||
/// never-pushed path <see cref="StatusNeverPushed"/> and nothing else does, so the two are tellable
|
||||
/// apart. A null snapshot is still skipped: that is a cache the refill did not populate, not an
|
||||
/// observation.</para></summary>
|
||||
private static uint WorstInputStatus(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||
{
|
||||
uint worst = 0u;
|
||||
var worstSeverity = 0u;
|
||||
foreach (var kv in cache)
|
||||
{
|
||||
if (kv.Value is null || kv.Value.Value is null) continue; // no data yet — not a quality signal
|
||||
// No entry at all — nothing was observed, so there is nothing to report on.
|
||||
if (kv.Value is null) continue;
|
||||
// Observed as "no data has ever arrived" — the cold-start placeholder, not a fault.
|
||||
if (kv.Value.StatusCode == StatusNeverPushed) continue;
|
||||
var status = kv.Value.StatusCode;
|
||||
var severity = status >> 30;
|
||||
if (severity > worstSeverity)
|
||||
|
||||
Reference in New Issue
Block a user