diff --git a/docs/AlarmTracking.md b/docs/AlarmTracking.md
index ce5e3c63..f6560a28 100644
--- a/docs/AlarmTracking.md
+++ b/docs/AlarmTracking.md
@@ -158,18 +158,46 @@ condition's state?") — mirroring the native OT semantic:
guard's job) — it contributes `Good`, so scripted conditions don't flash `Bad` at every deploy. The first
actually-`Bad` published value flips the bucket and annotates.
-**Coverage boundary (#478 as shipped).** Scripted quality tracks input tags whose driver **publishes a
-data change carrying a Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's
-per-item Bad). It does **not** yet cover a driver **comms loss**: a poll driver (Modbus/S7) whose device
-goes unreachable emits only `ConnectivityChanged` and goes *silent* on the value feed (see
-`DriverInstanceActor.Reconnecting`), so the scripted engine keeps the last-known Good value and the
-condition stays `Good`. Bridging driver connectivity into scripted inputs — the symmetric of the native
-`OnDriverConnectivityChanged` path above, plus resolving the null-value/cold-start asymmetry (a runtime
-`Bad` with a null value is currently indistinguishable from cold start and contributes `Good`) — is tracked
-as the Layer-4 follow-up (#481).
+**Comms loss (Layer 4, #481).** #478 covered input tags whose driver **publishes a data change carrying a
+Bad/Uncertain `StatusCode`** (e.g. an OpcUaClient input forwarding a server's per-item Bad). It did not
+cover a driver **comms loss**: a poll driver (Modbus/S7) whose device goes unreachable emits only
+`ConnectivityChanged` and goes *silent* on the value feed (see `DriverInstanceActor.Reconnecting`), so the
+engine kept the last-known 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` closes it, symmetrically with the native
+`OnDriverConnectivityChanged` path above: on `ConnectivityChanged(false)` it pushes a synthetic
+`BadNoCommunication` with a **null value** into the dependency mux for every value ref the driver owns,
+resolved from the existing `_nodeIdByDriverRef` map. Alarm-bearing tags are deliberately excluded — a
+native condition is not a mux value, and its comms quality goes through the Part 9 annotation instead.
+
+Two things worth knowing before changing this:
+
+- **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: the `Connected → Reconnecting` edge calls
+ `DetachSubscription`, so the reconnect's `ResubscribeDesired` builds a *fresh* subscription whose
+ `PollGroupEngine` initial push runs with `forceRaise: true` and re-publishes every tag with its real
+ quality. That detach is load-bearing — without it the poll engine would compare against its retained
+ `LastValues`, see an unchanged value, raise nothing, and strand those refs Bad forever.
+- **Virtual tags computed from a comms-lost input now go Bad too.** That follows from using the shared mux
+ and is the correct reading of "this input's source is unreachable", but it is a behaviour change beyond
+ scripted alarms.
+
+**Cold start vs. a driver reporting Bad (#478 Finding 3, closed by #481).** `WorstInputStatus` used to skip
+every input whose *value* was null as "no data yet". A comms-lost driver produces exactly that shape — Bad
+with no value — so the skip was hiding the case that matters most. The upstream source now stamps a
+never-pushed path `BadWaitingForInitialData` and nothing else does, so the engine skips *that specific
+code* rather than every null. Deleting the null-skip outright would have been 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 failure modes are pinned — see the engine-test pair below, each of
+which goes red under the opposite mistake.
Guards: `ScriptedAlarmEngineTests` (transition carries `Uncertain`; `Bad` input with no transition emits
-`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing),
+`QualityChanged(Bad)`; restore emits `QualityChanged(Good)`; unchanged bucket emits nothing; plus the #481
+pair — a pushed Bad carrying no value counts, an input never pushed does not),
+`DriverHostActorLiveValueTests` (comms loss pushes Bad for every ref the driver owns, reconnect pushes
+nothing, another driver's loss pushes nothing),
+`DependencyMuxTagUpstreamSourceTests.The_engine_agrees_with_this_source_on_the_never_pushed_status_code`,
`ScriptedAlarmSourceTests.QualityChanged_emission_raises_no_alarm_event`,
`DependencyMuxActorTests.Publish_quality_is_forwarded_on_DependencyValueChanged`, and
`ScriptedAlarmHostActorTests` (`Bad_quality_dependency_publishes_AlarmQualityUpdate_and_no_alerts`,
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
index 250953d9..ff3582fd 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
@@ -32,6 +32,17 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
///
public sealed class ScriptedAlarmEngine : IDisposable
{
+ ///
+ /// OPC UA Part 4 BadWaitingForInitialData — the status the upstream source stamps on a
+ /// path nothing has ever pushed to, and the marker uses to tell
+ /// genuine cold start apart from a driver reporting Bad.
+ /// Inlined rather than taken from Commons.OpcUa.OpcUaStatus because this project
+ /// deliberately does not reference Commons — the engine is pure evaluation logic over
+ /// Core.Abstractions. The value is fixed by the OPC UA spec, so the duplication cannot
+ /// drift; DependencyMuxTagUpstreamSourceTests pins the two agreeing.
+ ///
+ 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);
/// 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
- /// 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).
+ /// bits (top 2). An input that has never been pushed is NOT a quality signal: it means "no data", which
+ /// the 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).
+ /// "Never pushed" is a specific status code, not "the value is null" (#481, closing #478
+ /// Finding 3). 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 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.
private static uint WorstInputStatus(IReadOnlyDictionary 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)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index 87cd2fdc..950909b7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
@@ -1009,9 +1009,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// alerts row is published here — driver comms health has its own status surface
/// (); a row per condition would be alarm-fatigue.
///
+ ///
+ /// #481 (Layer 4) — the same transition also reaches SCRIPTED alarms, via the dependency
+ /// mux. #478 gave a scripted condition the worst quality of its input tags, but only for
+ /// inputs whose driver actually publishes a Bad data change. A poll driver whose device
+ /// goes unreachable publishes nothing at all — it emits this signal and goes silent — so the
+ /// engine kept 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. closes that by pushing a
+ /// synthetic Bad for every value ref the driver owns.
+ ///
///
private void OnDriverConnectivityChanged(DriverInstanceActor.ConnectivityChanged msg)
{
+ // #481: the mux fan-out is independent of the OPC UA sink — a node with scripted alarms but no
+ // publish actor (the mux-only test/dev path) must still see comms loss. Done before the early
+ // return for exactly that reason.
+ if (!msg.Connected) FanCommsLossToDependencyMux(msg.DriverInstanceId);
+
if (_opcUaPublishActor is null) return;
// #519: name the actual cause. This is a connectivity transition, so the Bad case is
@@ -1038,6 +1053,68 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId, msg.Connected ? "connected" : "disconnected", annotated, statusCode);
}
+ ///
+ /// #481 (Layer 4) — push a synthetic into the dependency
+ /// mux for every value ref the just-disconnected driver owns, so scripted alarms and virtual tags see
+ /// the source die instead of holding its last Good value indefinitely.
+ ///
+ ///
+ ///
+ /// The ref set already existed. #481 asked for a driver → all-tag-refs map to be built;
+ /// is exactly that, keyed (DriverInstanceId, RawPath) and
+ /// rebuilt from the composition's RawTags ∪ UnsReferenceVariables on every apply.
+ /// Alarm-bearing tags live in the separate and are
+ /// deliberately absent here — a native condition is not a mux value, and its comms quality is
+ /// annotated by the caller through the Part 9 path instead.
+ ///
+ ///
+ /// Value null, not the last-known value. The honest statement is "there is no
+ /// current value", and the engine's AreInputsReady gate holds the predicate on any Bad
+ /// regardless, so no script observes the null. It does mean the Bad must survive
+ /// WorstInputStatus, which until #481 skipped every null-valued entry as cold start —
+ /// hence the paired change stamping never-pushed paths BadWaitingForInitialData so the two
+ /// are tellable apart. Without that half this fan-out would be silently inert.
+ ///
+ ///
+ /// 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:
+ /// DriverInstanceActor calls DetachSubscription on the Connected → Reconnecting
+ /// edge, which nulls the subscription handle, so the reconnect's ResubscribeDesired builds
+ /// a fresh subscription whose PollGroupEngine initial push runs with
+ /// forceRaise: true and re-publishes every tag with its real quality. That detach is what
+ /// makes recovery work: without it the poll engine would compare against its retained
+ /// LastValues, see an unchanged Good value, raise nothing, and leave these refs Bad
+ /// forever — the value-dedup trap that bit the v3 Batch 4 virtual-tag reassert.
+ ///
+ ///
+ /// Ungated by redundancy role, for the same reason as the native-condition annotation above: a
+ /// Secondary must keep its scripted-alarm evaluation coherent for failover. The mux fan-out
+ /// reaches only refs something is actually subscribed to, so a driver whose tags feed no script
+ /// and no virtual tag costs one dictionary scan and no delivery.
+ ///
+ ///
+ /// The driver child that just reported comms loss.
+ private void FanCommsLossToDependencyMux(string driverInstanceId)
+ {
+ if (_dependencyMux is null) return;
+
+ var ts = DateTime.UtcNow;
+ var pushed = 0;
+ foreach (var (driverId, rawPath) in _nodeIdByDriverRef.Keys)
+ {
+ if (driverId != driverInstanceId) continue;
+
+ _dependencyMux.Tell(new DriverInstanceActor.AttributeValuePublished(
+ driverInstanceId, rawPath, Value: null, OpcUaStatus.BadNoCommunication, ts));
+ pushed++;
+ }
+
+ if (pushed > 0)
+ _log.Debug(
+ "DriverHost {Node}: driver {Driver} disconnected — pushed BadNoCommunication for {Count} mux ref(s)",
+ _localNode, driverInstanceId, pushed);
+ }
+
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{
if (_opcUaPublishActor is null) return;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs
index b58e6d7d..d0734f45 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
@@ -26,11 +27,20 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
///
public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
{
- // OPC UA Part 4 StatusCode for an outright Bad value (severity 10, bit 31 set). The
- // codebase has no shared Core.Abstractions constant — concrete producers (GalaxyDriver,
- // the Wonderware historian client) inline this same 0x80000000u, and the engine's own
- // AreInputsReady gate tests exactly this bit — so an "unknown path" snapshot uses it too.
- private const uint StatusBad = 0x80000000u;
+ // The status an "unknown path" snapshot carries — a path nothing has ever pushed to.
+ //
+ // This is deliberately BadWaitingForInitialData rather than the bare Bad (0x80000000u) it used
+ // to be, and the distinction is load-bearing (#481, closing #478 Finding 3). Both codes are Bad,
+ // so the engine's AreInputsReady gate treats them identically and the cold-start hold is
+ // unchanged. What changed is that "no data has ever arrived" is now tellable apart from "a
+ // driver pushed Bad", which previously looked identical whenever the pushed Bad carried a null
+ // value — the shape a comms-lost driver produces. WorstInputStatus keys off exactly this code to
+ // decide which of the two it is looking at.
+ //
+ // The code is also the honest one: Part 4 defines BadWaitingForInitialData as precisely this
+ // state, and it is what a freshly materialised OPC UA variable already reports before its first
+ // publish.
+ private const uint StatusNeverPushed = OpcUaStatus.BadWaitingForInitialData;
// Intentionally never pruned: cold-start semantics depend on retained last values so
// ReadTag can answer synchronously for any path that has ever been pushed.
@@ -90,9 +100,9 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
// No value has been pushed for this path yet — return a Bad-quality placeholder so
// the engine's cold-start guard holds the prior condition rather than NRE-ing on a
- // null value cast.
+ // null value cast. See StatusNeverPushed for why the specific code matters.
var now = _clock();
- return new DataValueSnapshot(Value: null, StatusCode: StatusBad, SourceTimestampUtc: null, ServerTimestampUtc: now);
+ return new DataValueSnapshot(Value: null, StatusCode: StatusNeverPushed, SourceTimestampUtc: null, ServerTimestampUtc: now);
}
// No-replay contract: a new subscriber does NOT receive a synthetic initial
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
index 6ad0a3fc..61316199 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs
@@ -38,11 +38,23 @@ public sealed class FakeUpstream : ITagUpstreamSource
}
}
- /// Reads the current value of a tag, or returns a bad-status snapshot if not set.
+ ///
+ /// OPC UA BadWaitingForInitialData — the status a never-pushed path reports, matching what
+ /// the production DependencyMuxTagUpstreamSource stamps.
+ /// This used to be 0x80340000 (BadNotConnected), a different Bad code from the
+ /// one production uses. That drift was invisible while WorstInputStatus skipped every
+ /// null-valued entry — any Bad code behaved the same. #481 made the specific code load-bearing (it
+ /// is how cold start is told apart from a driver reporting Bad), so a fake disagreeing with
+ /// production here would let every alarm with an unset input report Bad in tests and Good in the
+ /// real runtime, or vice versa.
+ ///
+ public const uint StatusNeverPushed = 0x80320000u;
+
+ /// Reads the current value of a tag, or returns a never-pushed snapshot if not set.
/// The tag path to read.
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v) ? v
- : new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
+ : new DataValueSnapshot(null, StatusNeverPushed, null, DateTime.UtcNow);
/// Subscribes an observer to tag changes for the given path.
/// The tag path to subscribe to.
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
index 1748070a..12786bb0 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs
@@ -1103,6 +1103,75 @@ public sealed class ScriptedAlarmEngineTests
events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
}
+ // -------------------------------------------------------------------------
+ // #481 — cold start vs. a driver reporting Bad (closes #478 Finding 3)
+ // -------------------------------------------------------------------------
+
+ ///
+ /// A driver reporting Bad with no value counts as Bad quality.
+ /// This is the hole #478 Finding 3 recorded. WorstInputStatus skipped every entry whose
+ /// value was null as "no data yet", which is indistinguishable from a real fault when the fault
+ /// carries no value — and a null-valued Bad is exactly what a comms-lost driver produces, so the
+ /// case being skipped was the case that mattered most. Before the fix this test sees no
+ /// QualityChanged at all.
+ ///
+ [Fact]
+ public async Task A_pushed_Bad_carrying_no_value_counts_as_Bad_quality()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50); // Good to start, so the bucket has somewhere to move from
+ using var eng = Build(up, out _);
+ await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
+ TestContext.Current.CancellationToken);
+
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ // The shape DriverHostActor pushes on ConnectivityChanged(false): Bad, no value.
+ up.Push("Temp", null, 0x80310000u); // BadNoCommunication
+ await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.QualityChanged));
+
+ var q = events.First(e => e.Emission == EmissionKind.QualityChanged);
+ (q.WorstInputStatusCode >> 30).ShouldBeGreaterThanOrEqualTo(2u);
+ // The sub-code survives, so an operator can tell comms loss from any other fault.
+ q.WorstInputStatusCode.ShouldBe(0x80310000u);
+ }
+
+ ///
+ /// …and the protection the old null-skip existed for still holds: an input nothing has ever pushed
+ /// does NOT drag the condition Bad.
+ /// The falsifying control for the test above. Deleting the null-skip outright — the obvious
+ /// way to make a null-valued Bad count — would also make every not-yet-published input count, which
+ /// is what the skip was there to prevent: every scripted condition flashing Bad at deploy until its
+ /// first push, flooding the quality path with load-time annotations. Both tests pass only if the
+ /// engine keys off the never-pushed status code specifically.
+ ///
+ [Fact]
+ public async Task An_input_that_was_never_pushed_does_not_drag_the_condition_Bad()
+ {
+ var up = new FakeUpstream();
+ up.Set("Temp", 50);
+ // "Spare" is never Set or Pushed, so the engine's cache refill resolves it through ReadTag and
+ // gets the never-pushed placeholder — value null, Bad severity, the same shape a genuine fault has.
+ using var eng = Build(up, out _);
+
+ // Subscribed BEFORE LoadAsync, deliberately. The quality bucket is tracked per alarm and only a
+ // CHANGE emits, so the load-time evaluation is the one that would flash Bad — subscribing after it
+ // makes this assertion vacuous, and the first draft of this test was exactly that: it passed
+ // against the broken behaviour because the flash had already happened by the time it was listening.
+ var events = new List();
+ eng.OnEvent += (_, e) => events.Add(e);
+
+ await eng.LoadAsync(
+ [Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100 || (int?)ctx.GetTag("Spare").Value == 1;""")],
+ TestContext.Current.CancellationToken);
+
+ up.Push("Temp", 60, 0u);
+ await Task.Delay(150, TestContext.Current.CancellationToken);
+
+ events.ShouldNotContain(e => e.Emission == EmissionKind.QualityChanged);
+ }
+
private static async Task WaitForAsync(Func cond, int timeoutMs = 2000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs
index a445fe22..df113425 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorLiveValueTests.cs
@@ -151,6 +151,94 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
}
+ // ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------
+ //
+ // #478 gave a scripted condition the worst quality of its inputs, 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 and
+ // went on evaluating it. These pin the synthetic Bad that closes that hole.
+
+ /// Comms loss pushes a Bad for every value ref the driver owns — not one, and not the
+ /// alarm-bearing ones (those take the Part 9 quality path instead).
+ [Fact]
+ public void Driver_comms_loss_pushes_BadNoCommunication_into_the_mux_for_every_ref_it_owns()
+ {
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedV3Deployment(db, RevA,
+ rawTags: new[]
+ {
+ (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false),
+ (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "temp", DataType: "Double", Writable: false),
+ },
+ unsRefs: Array.Empty<(string, string, string, string, string?)>());
+
+ var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
+
+ actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: false));
+
+ var pushes = new[]
+ {
+ mux.ExpectMsg(TimeSpan.FromSeconds(5)),
+ mux.ExpectMsg(TimeSpan.FromSeconds(5)),
+ };
+
+ pushes.Select(p => p.FullReference).ShouldBe(
+ new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" }, ignoreOrder: true);
+
+ foreach (var p in pushes)
+ {
+ // BadNoCommunication, not the bare Bad: #519's reasoning applies here too — the sub-code
+ // names the actual cause and reaches a client through the scripted condition's quality.
+ p.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
+ // Null rather than the last-known value: the honest statement is "no current value". The
+ // engine's AreInputsReady gate holds the predicate on any Bad, so no script sees the null.
+ p.Value.ShouldBeNull();
+ p.DriverInstanceId.ShouldBe("drv-1");
+ }
+
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
+ }
+
+ /// Reconnect pushes NOTHING — and this is the falsifying control for the test above, since
+ /// both drive the same handler over the same populated map and only one may produce traffic.
+ [Fact]
+ public void Driver_reconnect_pushes_nothing_into_the_mux()
+ {
+ // Publishing Good here would assert a value quality the host has not observed. Recovery instead
+ // rides the driver's own republish: the Connected -> Reconnecting edge calls DetachSubscription,
+ // so the reconnect builds a FRESH subscription whose PollGroupEngine initial push runs with
+ // forceRaise: true and re-publishes every tag with its real quality. Without that detach the poll
+ // engine would compare against retained LastValues, see an unchanged value, raise nothing, and
+ // strand these refs Bad forever — the value-dedup trap from the v3 Batch 4 virtual-tag reassert.
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedV3Deployment(db, RevA,
+ rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
+ unsRefs: Array.Empty<(string, string, string, string, string?)>());
+
+ var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
+
+ actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
+
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
+ }
+
+ /// Comms loss for a driver this host knows nothing about pushes nothing — the fan-out is
+ /// scoped by DriverInstanceId, so one dead device cannot Bad-out another driver's tags.
+ [Fact]
+ public void Comms_loss_for_another_driver_pushes_nothing_into_the_mux()
+ {
+ var db = NewInMemoryDbFactory();
+ var deploymentId = SeedV3Deployment(db, RevA,
+ rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
+ unsRefs: Array.Empty<(string, string, string, string, string?)>());
+
+ var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
+
+ actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-OTHER", Connected: false));
+
+ mux.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
+ }
+
/// Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs
index d26b5381..d020d118 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs
@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
@@ -40,6 +41,36 @@ public sealed class DependencyMuxTagUpstreamSourceTests
snap.StatusCode.ShouldNotBe(0u);
// Timestamp comes from the injected clock.
snap.ServerTimestampUtc.ShouldBe(fixedNow);
+
+ // #481: the SPECIFIC code is load-bearing, not merely "some Bad". ScriptedAlarmEngine skips
+ // exactly this code when computing worst-of-input quality, which is how it tells a path nothing
+ // has ever pushed to apart from a driver reporting Bad with no value — the shape a comms-lost
+ // driver produces, and previously indistinguishable from cold start. Any other Bad code here
+ // would make every not-yet-published input drag its condition Bad at deploy.
+ snap.StatusCode.ShouldBe(OpcUaStatus.BadWaitingForInitialData);
+ }
+
+ ///
+ /// The engine's private copy of the never-pushed sentinel agrees with the one this source stamps.
+ ///
+ ///
+ /// Core.ScriptedAlarms deliberately does not reference Commons, so the engine inlines
+ /// the constant rather than sharing . The value is fixed by the OPC UA spec
+ /// so it cannot legitimately drift, but a typo in either copy would silently un-wire #481: the
+ /// engine would stop recognising the placeholder and every cold-start input would report Bad. Read
+ /// by reflection because the engine's copy is private — deliberately, since nothing outside the
+ /// engine should key off it.
+ ///
+ [Fact]
+ public void The_engine_agrees_with_this_source_on_the_never_pushed_status_code()
+ {
+ var engineConst = typeof(Core.ScriptedAlarms.ScriptedAlarmEngine)
+ .GetField("StatusNeverPushed", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)
+ .ShouldNotBeNull("ScriptedAlarmEngine.StatusNeverPushed is gone or was renamed — #481's cold-start "
+ + "vs. reported-Bad distinction is wired through it, so this guard must be repointed, not deleted");
+
+ engineConst.GetRawConstantValue().ShouldBe(
+ new DependencyMuxTagUpstreamSource().ReadTag("never/pushed").StatusCode);
}
[Fact]