2d20aeb779
#519 — a driver's OPC UA status code now reaches a client with its sub-code intact. DriverInstanceActor projected the driver's uint onto a 3-state Good/Uncertain/Bad enum and OtOpcUaNodeManager re-expanded Bad to a bare 0x80000000, so BadNoCommunication, BadTypeMismatch and BadNotSupported were indistinguishable on the wire and every driver's status constants (incl. the 16 #497 had just corrected) were diagnostic in logs only. Resolved by option (b): the raw uint rides AttributeValuePublished -> AttributeValueUpdate -> IOpcUaAddressSpaceSink.WriteValue, OpcUaQuality is deleted, and severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad). The surface was wider than the issue costed (41 files, not "the sink and 3 implementations"): OpcUaQuality had non-driver producers with no uint to carry. Each got its natural code — BadInternalError for a failed virtual-tag script, BadNoCommunication for driver comms loss — and ScriptedAlarmHostActor's pair of mutually-inverse uint<->enum mappers is deleted, having round-tripped a code the engine already held. AddressSpaceApplier's removal writes deliberately keep the generic Bad: not losing a code the driver had is this issue; inventing one for the deploy path is a separate call. Behaviour change: WriteAlarmQuality fires its Part 9 event on a status-code change rather than a severity-bucket change. While the annotation was 3-state those were the same predicate; now a move between two Bad reasons changes what a subscriber reads. New SDK-free named constants live on Commons.OpcUa.OpcUaStatus, and StatusCodeParityTests is widened to cover them — that guard is reflection-driven, so it can only see a named constant, never an inline literal. #517 — the symptom is real but the issue named the wrong path and its fix was unsafe. ShutdownAsync cannot race: DriverInstanceActor calls it from PostStop, after the mailbox drains. (Core/Hosting/DriverHost, the one class with a concurrent GetHealth, has no production call site.) InitializeAsync can: it runs on a fire-and-forget Task.Run, and MTConnectDriver writes Healthy then starts loops that can throw into a catch which disposes the client before writing Faulted. Moving the write earlier, as suggested, inverts the terminal state — teardown drains in-flight loops that write health as they unwind, so a late loop could leave Healthy at rest with every connection closed. The fix is a bracket (conservative before, terminal after), applied to MTConnect's init-failure path only; shutdown keeps its single trailing write, and the rule plus the reachability table are documented on IDriver.ShutdownAsync. CalculationDriver.GetHealth returned a hard-coded Healthy that consulted nothing — Healthy before initialize, after shutdown and after dispose. Now derived from _running. Its test, named Health_is_always_Connected, asserted the defect and is replaced. Every new guard was falsified by breaking the production code and watching it go red. The two sub-code tests each assert two different Bad codes, since a single-code assertion could pass against a hard-coded constant under the old projection. Pre-existing failures, verified unchanged on a stashed tree and not chased: 4 in Driver.AbLegacy.IntegrationTests (slc500/micrologix/plc5 fixtures not up) and 3 in Host.IntegrationTests. Closes #517 Closes #519 Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
254 lines
13 KiB
C#
254 lines
13 KiB
C#
using System.Text.Json;
|
||
using Akka.Actor;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||
|
||
/// <summary>
|
||
/// v3 Batch 4 (B4-WP3) — the driver <b>single-source fan-out</b> wired into
|
||
/// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
|
||
/// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
|
||
/// <c>RawTags</c> ∪ <c>UnsReferenceVariables</c> — resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
|
||
/// NodeId AND every referencing UNS NodeId, so <c>ForwardToMux</c> lands ONE publish on the raw node
|
||
/// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
|
||
/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
|
||
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
|
||
/// <para>
|
||
/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain
|
||
/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting
|
||
/// each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
|
||
/// <c>ApplyAndAck → PushDesiredSubscriptions</c> pass that builds the map; the sink and mux are
|
||
/// injected as <see cref="Akka.TestKit.TestProbe"/>s.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
|
||
{
|
||
private static readonly NodeId TestNode = NodeId.Parse("driver-lv-test");
|
||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
|
||
|
||
/// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
|
||
/// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
|
||
[Fact]
|
||
public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value()
|
||
{
|
||
var db = NewInMemoryDbFactory();
|
||
var deploymentId = SeedV3Deployment(db, RevA,
|
||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
|
||
|
||
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
||
|
||
// The driver publishes by its wire-ref RawPath.
|
||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, Ts));
|
||
|
||
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||
var byId = new[] { a, b }.ToDictionary(u => u.NodeId);
|
||
|
||
byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
|
||
// Realm travels with each update so the sink resolves the right namespace.
|
||
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||
// Fan-out drift guard: identical value + quality + timestamp on BOTH node states.
|
||
foreach (var u in byId.Values)
|
||
{
|
||
u.Value.ShouldBe(42.0);
|
||
u.StatusCode.ShouldBe(OpcUaStatus.Good);
|
||
u.TimestampUtc.ShouldBe(Ts);
|
||
}
|
||
|
||
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
|
||
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
||
.FullReference.ShouldBe("Plant/Modbus/dev1/speed");
|
||
}
|
||
|
||
/// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
|
||
[Fact]
|
||
public void Unreferenced_raw_tag_fans_to_raw_node_only()
|
||
{
|
||
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, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||
|
||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaStatus.Good, Ts));
|
||
|
||
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
|
||
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
|
||
only.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||
}
|
||
|
||
/// <summary>A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates,
|
||
/// identical value) — the 1:N fan-out.</summary>
|
||
[Fact]
|
||
public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns()
|
||
{
|
||
var db = NewInMemoryDbFactory();
|
||
var deploymentId = SeedV3Deployment(db, RevA,
|
||
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
|
||
unsRefs: new[]
|
||
{
|
||
(Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null),
|
||
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
|
||
});
|
||
|
||
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
|
||
|
||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaStatus.Good, Ts));
|
||
|
||
var updates = new[]
|
||
{
|
||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
|
||
};
|
||
updates.Select(u => u.NodeId).ShouldBe(new[]
|
||
{
|
||
"Plant/Modbus/dev1/speed",
|
||
"filling/line1/station1/speed",
|
||
"filling/line1/station2/speed",
|
||
}, ignoreOrder: true);
|
||
updates.ShouldAllBe(u => (double)u.Value! == 7.0);
|
||
}
|
||
|
||
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
|
||
/// NodeId), but is still forwarded to the dependency mux.</summary>
|
||
[Fact]
|
||
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_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, publish, mux) = SpawnHostAndApply(db, deploymentId);
|
||
|
||
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
|
||
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, Ts));
|
||
|
||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
|
||
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
|
||
}
|
||
|
||
/// <summary>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.</summary>
|
||
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
|
||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||
{
|
||
var coordinator = CreateTestProbe();
|
||
var publish = CreateTestProbe();
|
||
var mux = CreateTestProbe();
|
||
var vtHost = CreateTestProbe();
|
||
|
||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||
db, TestNode, coordinator.Ref,
|
||
localRoles: new HashSet<string> { "driver" },
|
||
dependencyMux: mux.Ref,
|
||
opcUaPublishActor: publish.Ref,
|
||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||
virtualTagHostOverride: vtHost.Ref));
|
||
|
||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
|
||
|
||
return (actor, publish, mux);
|
||
}
|
||
|
||
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
|
||
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
|
||
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
|
||
/// numerically (AccessLevel: ReadWrite = 1).</summary>
|
||
private static DeploymentId SeedV3Deployment(
|
||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
||
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
|
||
{
|
||
var drivers = rawTags
|
||
.Select(t => (t.Driver, t.DriverName))
|
||
.Distinct()
|
||
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
|
||
.ToArray();
|
||
var devices = rawTags
|
||
.Select(t => (t.Driver, t.Device))
|
||
.Distinct()
|
||
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
|
||
.ToArray();
|
||
var tags = rawTags.Select(t => new
|
||
{
|
||
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
|
||
DeviceId = $"{t.Driver}:{t.Device}",
|
||
TagGroupId = (string?)null,
|
||
Name = t.Tag,
|
||
DataType = t.DataType,
|
||
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
|
||
TagConfig = "{}",
|
||
}).ToArray();
|
||
|
||
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
|
||
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
|
||
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
|
||
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
|
||
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
|
||
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
|
||
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
|
||
var unsTagReferences = unsRefs.Select((r, i) => new
|
||
{
|
||
UnsTagReferenceId = $"ref-{i}",
|
||
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
|
||
TagId = r.BackingTag,
|
||
DisplayNameOverride = r.Effective,
|
||
}).ToArray();
|
||
|
||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||
{
|
||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||
DriverInstances = drivers,
|
||
Devices = devices,
|
||
TagGroups = Array.Empty<object>(),
|
||
Tags = tags,
|
||
UnsAreas = areas,
|
||
UnsLines = lines,
|
||
Equipment = equipment,
|
||
UnsTagReferences = unsTagReferences,
|
||
});
|
||
|
||
var id = DeploymentId.NewId();
|
||
using var ctx = db.CreateDbContext();
|
||
ctx.Deployments.Add(new Deployment
|
||
{
|
||
DeploymentId = id.Value,
|
||
RevisionHash = rev.Value,
|
||
Status = DeploymentStatus.Sealed,
|
||
CreatedBy = "test",
|
||
SealedAtUtc = DateTime.UtcNow,
|
||
ArtifactBlob = artifact,
|
||
});
|
||
ctx.SaveChanges();
|
||
return id;
|
||
}
|
||
}
|