fix(dcl): treat OPC UA BadResourceUnavailable as transient Bad; Bad+null keeps last-known value (ScadaBridge#112)
OtOpcUa's out-of-process script runner publishes 0x80040000 with a null value. - OpcUaDataConnection: logs entry/recovery once per tag, names the symbolic status in read errors; never a disconnect (not in the reconnect status set). - InstanceActor: a Bad update with no value is a quality-only transition (keep value, quality Bad, stream publish, no script/alarm/waiter re-eval), matching the connection-drop rule. Bad with a value is unchanged. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -768,6 +768,92 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
Assert.Equal(1450, response.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#112: a Bad-quality update that carries NO value (OtOpcUa sends a null Value
|
||||
/// with every Bad status, e.g. BadResourceUnavailable from its out-of-process script runner)
|
||||
/// is a quality-only transition — the last-known value survives, quality goes Bad, waiters
|
||||
/// and children are not re-evaluated against a null that never existed, and the next Good
|
||||
/// update recovers the attribute. Pins the Good / Bad(null) / Good sequence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_BadQualityWithNullValue_KeepsLastKnownValue_AndRecoversOnGood()
|
||||
{
|
||||
const string tag = "nsu=https://zb.com/otopcua/uns;s=Line1.Pump.Calc";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-BadNull",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Calc", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "OtOpcUa"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-BadNull", config, dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("OtOpcUa", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
|
||||
// A waiter that would fire if the stored value were ever nulled.
|
||||
actor.Tell(new WaitForAttributeRequest(
|
||||
"wfa-null", "Pump-BadNull", "Calc",
|
||||
null, v => v is null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
// Source goes BadResourceUnavailable: Bad quality, null value.
|
||||
actor.Tell(new TagValueUpdate("OtOpcUa", tag, null, QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-bad", "Pump-BadNull", "Calc", DateTimeOffset.UtcNow));
|
||||
var bad = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.True(bad.Found);
|
||||
Assert.Equal("Bad", bad.Quality);
|
||||
Assert.Equal(1450, bad.Value);
|
||||
|
||||
// Next good evaluation recovers.
|
||||
actor.Tell(new TagValueUpdate("OtOpcUa", tag, 1500, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
actor.Tell(new GetAttributeRequest("corr-good", "Pump-BadNull", "Calc", DateTimeOffset.UtcNow));
|
||||
var good = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Good", good.Quality);
|
||||
Assert.Equal(1500, good.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guard for ScadaBridge#112: a Bad update that DOES carry a value keeps the historical
|
||||
/// behaviour — the carried value is stored with Bad quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InstanceActor_BadQualityWithValue_StoresCarriedValue()
|
||||
{
|
||||
const string tag = "ns=3;s=Pump.BadWithValue";
|
||||
var config = new FlattenedConfiguration
|
||||
{
|
||||
InstanceUniqueName = "Pump-BadValue",
|
||||
Attributes =
|
||||
[
|
||||
new ResolvedAttribute
|
||||
{
|
||||
CanonicalName = "Speed", Value = "0", DataType = "Int32",
|
||||
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = CreateInstanceActorWithDcl("Pump-BadValue", config, dcl);
|
||||
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||
actor.Tell(new TagValueUpdate("PLC", tag, 1300, QualityCode.Bad, DateTimeOffset.UtcNow));
|
||||
|
||||
actor.Tell(new GetAttributeRequest("corr-bv", "Pump-BadValue", "Speed", DateTimeOffset.UtcNow));
|
||||
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("Bad", response.Quality);
|
||||
Assert.Equal(1300, response.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MV (C1 fix): a WRITE to a data-sourced <c>DataType.List</c> attribute must
|
||||
/// send the DCL a TYPED collection (so OPC UA writes an array node), NOT the
|
||||
|
||||
Reference in New Issue
Block a user