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:
@@ -399,6 +399,7 @@ Subscription demand survives connection transitions through coalescing and owner
|
||||
The DCL manages connection lifecycle automatically:
|
||||
|
||||
1. **Connection drop detection**: When a connection to a data source is lost, the DCL immediately pushes a value update with quality `bad` for **every tag subscribed on that connection**. Instance Actors see the staleness immediately and publish it to the site stream / debug view; **script and alarm actors are deliberately NOT re-notified on a quality-only change** (no value changed — re-evaluating triggers would cause spurious firings). Scripts observe quality on their next attribute read; a computed alarm holds its last-known state until fresh values arrive.
|
||||
**Per-tag Bad with no value is handled the same way (ScadaBridge#112).** A tag update whose quality is `bad` and whose value is null — OPC UA servers commonly omit the value on a Bad status, and OtOpcUa always does — is a quality-only transition at the Instance Actor: the last-known value is kept, quality goes `Bad`, the stream/debug view is told, scripts/alarms/`WaitForAttribute` waiters are not re-evaluated, and the next Good update recovers it. A Bad update that carries a value still stores that value. This covers OtOpcUa's `BadResourceUnavailable` (`0x80040000`, its out-of-process script runner's "worker restarted / runner unavailable" on virtual and Calculation tags, lmxopcua#780): the OPC UA adapter treats it as a plain per-node Bad — never a connection fault (it is not in the DataConnectionActor's session/secure-channel reconnect set), no subscription teardown — logs one Information line per tag on entry and one on recovery, and names it in read errors (`0x80040000 BadResourceUnavailable (source node temporarily unable to compute the value)`; every other Bad read error now carries its symbolic name too).
|
||||
2. **Auto-reconnect with fixed interval**: The DCL retries the connection at a configurable fixed interval (e.g., every 5 seconds). The retry interval is a **shared setting for all data connections** (`ReconnectInterval` in the Shared Settings table). This is consistent with the fixed-interval retry philosophy used throughout the system. Individual gRPC/OPC UA operations (reads, writes) fail immediately to the caller on error; there is no operation-level retry within the adapter.
|
||||
3. **Connection state transitions**: The DCL tracks each connection's state as `connected`, `disconnected`, or `reconnecting`. All transitions are logged to Site Event Logging (event type `connection`): the initial connect (`Connection established on … endpoint`, Info — added by review 2026-10-21, it previously logged nothing), connection lost / reconfigured (`Warning` / `Info`), restored on reconnect (`Info`) and failover (`Warning`).
|
||||
4. **Transparent re-subscribe**: On successful reconnection, the DCL automatically re-establishes all previously active subscriptions for that connection. Instance Actors require no action — they simply see quality return to `good` as fresh values arrive from restored subscriptions.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
|
||||
@@ -41,6 +42,14 @@ public class OpcUaDataConnection
|
||||
private StaleTagMonitor? _staleMonitor;
|
||||
private string? _heartbeatSubscriptionId;
|
||||
|
||||
/// <summary>
|
||||
/// Tags whose latest notification is <c>BadResourceUnavailable</c> (ScadaBridge#112), so the
|
||||
/// distinct "source temporarily unable to compute" log line fires once per entry and once per
|
||||
/// recovery rather than per notification. Written from the SDK publish threads, hence
|
||||
/// concurrent (DCL-019). Bounded by the subscribed tag set; cleared on (re)connect.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, byte> _resourceUnavailableTags = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="OpcUaDataConnection"/>.
|
||||
/// </summary>
|
||||
@@ -99,6 +108,7 @@ public class OpcUaDataConnection
|
||||
MaxMonitoredItemsPerSubscription: config.MaxMonitoredItemsPerSubscription);
|
||||
|
||||
_status = ConnectionHealth.Connecting;
|
||||
_resourceUnavailableTags.Clear();
|
||||
|
||||
// Reconnect reuses this adapter instance (DataConnectionActor re-calls
|
||||
// ConnectAsync on auto-reconnect): detach + dispose the previous client so
|
||||
@@ -191,6 +201,7 @@ public class OpcUaDataConnection
|
||||
(nodeId, value, timestamp, statusCode) =>
|
||||
{
|
||||
var quality = MapStatusCode(statusCode);
|
||||
TrackResourceUnavailable(tagPath, statusCode);
|
||||
callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -209,6 +220,7 @@ public class OpcUaDataConnection
|
||||
(nodeId, value, timestamp, statusCode) =>
|
||||
{
|
||||
var quality = MapStatusCode(statusCode);
|
||||
TrackResourceUnavailable(nodeId, statusCode);
|
||||
callback(nodeId, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)));
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -267,7 +279,7 @@ public class OpcUaDataConnection
|
||||
var quality = MapStatusCode(statusCode);
|
||||
|
||||
if (quality == QualityCode.Bad)
|
||||
return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}");
|
||||
return new ReadResult(false, null, DescribeBadRead(statusCode));
|
||||
|
||||
return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null);
|
||||
}
|
||||
@@ -306,7 +318,7 @@ public class OpcUaDataConnection
|
||||
|
||||
var quality = MapStatusCode(outcome.StatusCode);
|
||||
results[outcome.NodeId] = quality == QualityCode.Bad
|
||||
? new ReadResult(false, null, $"OPC UA read returned bad status: 0x{outcome.StatusCode:X8}")
|
||||
? new ReadResult(false, null, DescribeBadRead(outcome.StatusCode))
|
||||
: new ReadResult(true,
|
||||
new TagValue(outcome.Value, quality, new DateTimeOffset(outcome.SourceTimestamp, TimeSpan.Zero)),
|
||||
null);
|
||||
@@ -461,6 +473,60 @@ public class OpcUaDataConnection
|
||||
Disconnected?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>BadResourceUnavailable</c> — the code part (high 16 bits) of
|
||||
/// <see cref="Opc.Ua.StatusCodes.BadResourceUnavailable"/>. OtOpcUa's out-of-process script
|
||||
/// runner publishes it on script-computed nodes when the script worker died under the
|
||||
/// evaluation or no worker is ready (ScadaBridge#112, lmxopcua#780). It is a per-node,
|
||||
/// transient "cannot compute this value right now": a plain Bad quality on the tag — never a
|
||||
/// connection fault, so it must not reach <c>RaiseDisconnected</c> or the connection-level
|
||||
/// status set the DataConnectionActor reconnects on.
|
||||
/// </summary>
|
||||
internal const uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable;
|
||||
|
||||
/// <summary>True when <paramref name="statusCode"/>'s code part is BadResourceUnavailable
|
||||
/// (the low 16 bits are info bits and do not change the meaning).</summary>
|
||||
internal static bool IsResourceUnavailable(uint statusCode) =>
|
||||
(statusCode & 0xFFFF0000u) == BadResourceUnavailableCode;
|
||||
|
||||
/// <summary>Read-failure text naming the symbolic status, e.g.
|
||||
/// <c>0x80040000 BadResourceUnavailable</c>, so a script/diagnostic reader can tell a
|
||||
/// transient source-side condition from a configuration or security error.</summary>
|
||||
internal static string DescribeBadRead(uint statusCode)
|
||||
{
|
||||
var name = Opc.Ua.StatusCodes.LookupSymbolicId(statusCode & 0xFFFF0000u);
|
||||
var text = string.IsNullOrEmpty(name)
|
||||
? $"OPC UA read returned bad status: 0x{statusCode:X8}"
|
||||
: $"OPC UA read returned bad status: 0x{statusCode:X8} {name}";
|
||||
return IsResourceUnavailable(statusCode)
|
||||
? text + " (source node temporarily unable to compute the value)"
|
||||
: text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the entry into / recovery from <c>BadResourceUnavailable</c> once per transition per
|
||||
/// tag. Hot path: a tag that is not and was not unavailable costs one status compare and an
|
||||
/// empty-dictionary check.
|
||||
/// </summary>
|
||||
private void TrackResourceUnavailable(string tagPath, uint statusCode)
|
||||
{
|
||||
if (IsResourceUnavailable(statusCode))
|
||||
{
|
||||
if (_resourceUnavailableTags.TryAdd(tagPath, 0))
|
||||
_logger.LogInformation(
|
||||
"OPC UA source {Endpoint} reports BadResourceUnavailable (0x{Status:X8}) for {TagPath} — "
|
||||
+ "the source node is temporarily unable to compute this value; tag quality is Bad, "
|
||||
+ "last-known value kept, connection unaffected",
|
||||
_endpointUrl, statusCode, tagPath);
|
||||
}
|
||||
else if (!_resourceUnavailableTags.IsEmpty && _resourceUnavailableTags.TryRemove(tagPath, out _))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"OPC UA source {Endpoint} recovered {TagPath} from BadResourceUnavailable (now 0x{Status:X8})",
|
||||
_endpointUrl, tagPath, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps OPC UA StatusCode to QualityCode.
|
||||
/// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain.
|
||||
|
||||
@@ -1223,6 +1223,23 @@ public class InstanceActor : ReceiveActor
|
||||
traced = true;
|
||||
}
|
||||
|
||||
// Bad quality WITHOUT a value (ScadaBridge#112) is a quality-only transition: the
|
||||
// source is saying "I cannot give you a value right now", not "the value is now
|
||||
// null". OPC UA servers commonly omit the value on a Bad status — OtOpcUa always
|
||||
// does, e.g. BadResourceUnavailable (0x80040000) from its out-of-process script
|
||||
// runner — and the DCL's own tag-resolution failure pushes Bad+null too. Treat it
|
||||
// exactly like a connection drop (HandleConnectionQualityChanged, DCL design
|
||||
// "Connection drop detection"): keep the last-known value, flip quality to Bad,
|
||||
// publish to the stream, and do NOT re-notify scripts/alarms or evaluate waiters —
|
||||
// nulling the stored value would fire value-change scripts with a null and make a
|
||||
// computed alarm re-evaluate on a value that never existed. A Bad update that DOES
|
||||
// carry a value still takes the normal path below.
|
||||
if (update.Quality == QualityCode.Bad && update.Value is null)
|
||||
{
|
||||
ApplyQualityOnlyBad(attrName, update.TagPath, update.Timestamp, traceSource, traced);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A List-typed attribute coerces the incoming OPC UA array
|
||||
// (a CLR array/IEnumerable from the SDK) into a typed List<T>. On an
|
||||
// element-type mismatch we set the attribute's quality to Bad, log a
|
||||
@@ -1271,6 +1288,22 @@ public class InstanceActor : ReceiveActor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a data-sourced attribute Bad while keeping its last-known value (ScadaBridge#112):
|
||||
/// quality + timestamp move, the stored value does not, and only the site stream / debug
|
||||
/// view is told — mirroring <see cref="HandleConnectionQualityChanged"/>.
|
||||
/// </summary>
|
||||
private void ApplyQualityOnlyBad(
|
||||
string attrName, string tagPath, DateTimeOffset timestamp, Guid? traceSource, bool traced)
|
||||
{
|
||||
_attributeQualities[attrName] = "Bad";
|
||||
_attributeTimestamps[attrName] = timestamp;
|
||||
_attributes.TryGetValue(attrName, out var currentValue);
|
||||
_streamManager?.PublishAttributeValueChanged(new AttributeValueChanged(
|
||||
_instanceUniqueName, tagPath, attrName, currentValue, "Bad", timestamp,
|
||||
SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced));
|
||||
}
|
||||
|
||||
/// <summary>Parses a <see cref="ResolvedAttribute.DataType"/> string, defaulting to
|
||||
/// <see cref="DataType.String"/> on an unrecognized value (never throws).</summary>
|
||||
private static DataType ParseDataType(string dataType) =>
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#112: OtOpcUa's out-of-process script runner publishes
|
||||
/// <c>BadResourceUnavailable</c> (0x80040000) — with a null value — on script-computed nodes
|
||||
/// when its worker died or is not ready. The OPC UA adapter must treat it as a plain, transient
|
||||
/// Bad quality on the tag: no disconnect, no subscription teardown, distinct log lines on entry
|
||||
/// and recovery, and a read error that names the status.
|
||||
/// </summary>
|
||||
public class OpcUaResourceUnavailableTests
|
||||
{
|
||||
private const uint BadResourceUnavailable = 0x80040000u;
|
||||
private const string Tag = "nsu=https://zb.com/otopcua/uns;s=Line1.Pump.Calc";
|
||||
|
||||
private sealed class CapturingLogger : ILogger<OpcUaDataConnection>
|
||||
{
|
||||
public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) => Entries.Enqueue((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Good / BadResourceUnavailable(null) / Good on a monitored item maps to Good / Bad / Good
|
||||
/// tag quality, never raises Disconnected, never removes the subscription, and logs the
|
||||
/// unavailable entry once (not per notification) plus one recovery line.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Subscription_GoodResourceUnavailableGood_IsTransientBadQuality_NotAConnectionFault()
|
||||
{
|
||||
var client = Substitute.For<IOpcUaClient>();
|
||||
var factory = Substitute.For<IOpcUaClientFactory>();
|
||||
factory.Create().Returns(client);
|
||||
client.IsConnected.Returns(true);
|
||||
|
||||
Action<string, object?, DateTime, uint>? publish = null;
|
||||
client.CreateSubscriptionAsync(Tag, Arg.Do<Action<string, object?, DateTime, uint>>(cb => publish = cb),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns("sub-1");
|
||||
|
||||
var logger = new CapturingLogger();
|
||||
var adapter = new OpcUaDataConnection(factory, logger);
|
||||
var disconnected = 0;
|
||||
adapter.Disconnected += () => Interlocked.Increment(ref disconnected);
|
||||
|
||||
await adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
var received = new List<TagValue>();
|
||||
await adapter.SubscribeAsync(Tag, (_, v) => received.Add(v));
|
||||
Assert.NotNull(publish);
|
||||
|
||||
publish!(Tag, 42.0, DateTime.UtcNow, 0u);
|
||||
publish(Tag, null, DateTime.UtcNow, BadResourceUnavailable);
|
||||
publish(Tag, null, DateTime.UtcNow, BadResourceUnavailable); // repeated: no second log line
|
||||
publish(Tag, 43.0, DateTime.UtcNow, 0u);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { QualityCode.Good, QualityCode.Bad, QualityCode.Bad, QualityCode.Good },
|
||||
received.Select(v => v.Quality).ToArray());
|
||||
Assert.Null(received[1].Value);
|
||||
Assert.Equal(43.0, received[3].Value);
|
||||
|
||||
Assert.Equal(0, disconnected);
|
||||
Assert.Equal(ConnectionHealth.Connected, adapter.Status);
|
||||
await client.DidNotReceiveWithAnyArgs().RemoveSubscriptionAsync(default!, default);
|
||||
await client.DidNotReceiveWithAnyArgs().DisconnectAsync(default);
|
||||
|
||||
var unavailable = logger.Entries.Where(e => e.Message.Contains("reports BadResourceUnavailable")).ToList();
|
||||
var recovered = logger.Entries.Where(e => e.Message.Contains("recovered") && e.Message.Contains(Tag)).ToList();
|
||||
Assert.Single(unavailable);
|
||||
Assert.Contains(Tag, unavailable[0].Message);
|
||||
Assert.Single(recovered);
|
||||
}
|
||||
|
||||
/// <summary>A read that returns BadResourceUnavailable fails the read without a disconnect and
|
||||
/// names the symbolic status so it reads as a transient source-side condition.</summary>
|
||||
[Fact]
|
||||
public async Task Read_ResourceUnavailable_FailsReadWithNamedStatus_NoDisconnect()
|
||||
{
|
||||
var client = Substitute.For<IOpcUaClient>();
|
||||
var factory = Substitute.For<IOpcUaClientFactory>();
|
||||
factory.Create().Returns(client);
|
||||
client.IsConnected.Returns(true);
|
||||
client.ReadValueAsync(Tag, Arg.Any<CancellationToken>())
|
||||
.Returns(((object?)null, DateTime.UtcNow, BadResourceUnavailable));
|
||||
|
||||
var adapter = new OpcUaDataConnection(factory, new CapturingLogger());
|
||||
var disconnected = 0;
|
||||
adapter.Disconnected += () => disconnected++;
|
||||
await adapter.ConnectAsync(new Dictionary<string, string>());
|
||||
|
||||
var result = await adapter.ReadAsync(Tag);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("0x80040000", result.ErrorMessage);
|
||||
Assert.Contains("BadResourceUnavailable", result.ErrorMessage);
|
||||
Assert.Contains("temporarily unable", result.ErrorMessage);
|
||||
Assert.Equal(0, disconnected);
|
||||
Assert.Equal(ConnectionHealth.Connected, adapter.Status);
|
||||
}
|
||||
|
||||
/// <summary>Other Bad reads still get the symbolic name but not the transient wording.</summary>
|
||||
[Fact]
|
||||
public void DescribeBadRead_OtherCodes_NamedWithoutTransientWording()
|
||||
{
|
||||
var text = OpcUaDataConnection.DescribeBadRead(0x80340000u);
|
||||
Assert.Contains("BadNodeIdUnknown", text);
|
||||
Assert.DoesNotContain("temporarily", text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BadResourceUnavailable is a per-node status: the DataConnectionActor must never classify it
|
||||
/// as a session/secure-channel failure, which would drive a reconnect and a subscription rebuild.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResourceUnavailable_IsNotConnectionLevelStatus()
|
||||
{
|
||||
var method = typeof(DataConnectionActor).GetMethod("IsConnectionLevelStatus",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
Assert.NotNull(method);
|
||||
Assert.False((bool)method!.Invoke(null, new object[] { BadResourceUnavailable })!);
|
||||
// Positive control: a genuine session code IS connection-level.
|
||||
Assert.True((bool)method.Invoke(null, new object[] { 0x80250000u /* BadSessionIdInvalid */ })!);
|
||||
}
|
||||
}
|
||||
@@ -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