feat(runtime,host): close F7 — driver subscribe + write paths + Host DI
Some checks failed
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Some checks failed
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
Three pieces landed in one batch, closing F7-residual + Host DI #106: Runtime/DriverInstanceActor: - Subscribe / Unsubscribe message contracts; the Connected state handles them via IDriver.ISubscribable. On every OnDataChange event the actor publishes AttributeValuePublished to its parent (DriverHostActor → OpcUaPublishActor). OPC UA StatusCode is mapped to the 3-state OpcUaQuality enum via severity bits (00=Good, 01=Uncertain, 10/11=Bad). - DetachSubscription tears the handler off the driver on DisconnectObserved, Unsubscribe, and PostStop so a stale handler never pushes to a dead actor. - WriteAttribute now dispatches IWritable.WriteAsync (batch of one) with a 5s CancellationTokenSource; status-code propagated to WriteAttributeResult on non-Good results. Host: - New ProjectReferences to Core + every cross-platform driver assembly (AbCip/AbLegacy/FOCAS/Galaxy/Modbus/S7/TwinCAT). Galaxy is net10 (gRPC client to mxaccessgw); the COM-bound net48 Wonderware Historian driver stays out of the Host's reference closure — its .Client gRPC wrapper is what binds for historian needs. - New DriverFactoryBootstrap.AddOtOpcUaDriverFactories() registers a singleton DriverFactoryRegistry, invokes each driver's Register(registry, loggerFactory), and binds IDriverFactory to DriverFactoryRegistryAdapter. Replaces the F7 NullDriverFactory default so deploys actually materialise real IDriver instances on driver-role nodes. ShouldStub() still gates per-platform behaviour at spawn time. - Program.cs wires AddOtOpcUaDriverFactories() before AddAkka so the runtime extension can resolve IDriverFactory from DI. Tests: Runtime 46 -> 52 (+6): - Write returns success when StatusCode = Good - Write propagates non-Good status code in failure Reason - Subscribe forwards OnDataChange to parent as AttributeValuePublished - Quality translation: Uncertain (0x40...) and Bad (0x80...) - Subscribe against non-ISubscribable returns failure - DisconnectObserved detaches handler so late events are dropped All 6 v2 test suites green: 152 tests passing. Closes F7. F7-residual sub-tasks #110 (subscribe) and #111 (write) both shipped. Host DI binding #106 shipped.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
@@ -61,7 +62,128 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
reply.Reason!.ShouldContain("IWritable");
|
||||
}
|
||||
|
||||
private sealed class StubDriver : IDriver
|
||||
[Fact]
|
||||
public async Task Write_against_IWritable_returns_success_when_status_is_Good()
|
||||
{
|
||||
var driver = new WritableStubDriver();
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
||||
|
||||
var reply = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
||||
new DriverInstanceActor.WriteAttribute("tag-1", 42),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
reply.Success.ShouldBeTrue();
|
||||
driver.Writes.Single().FullReference.ShouldBe("tag-1");
|
||||
driver.Writes.Single().Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_propagates_status_code_on_Bad_result()
|
||||
{
|
||||
const uint badStatus = 0x80340000; // BadOutOfService — top severity bits = 10b
|
||||
var driver = new WritableStubDriver { NextStatusCode = badStatus };
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
||||
|
||||
var reply = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
|
||||
new DriverInstanceActor.WriteAttribute("tag-1", 42),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
reply.Success.ShouldBeFalse();
|
||||
reply.Reason!.ShouldContain("80340000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_against_ISubscribable_forwards_OnDataChange_to_parent()
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
||||
|
||||
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
|
||||
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(250)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// Driver fires an OnDataChange — actor should forward it to its parent as
|
||||
// AttributeValuePublished with Quality mapped from StatusCode.
|
||||
driver.FireDataChange("tag-a", value: 3.14, statusCode: 0u);
|
||||
|
||||
var published = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2));
|
||||
published.FullReference.ShouldBe("tag-a");
|
||||
published.Value.ShouldBe(3.14);
|
||||
published.Quality.ShouldBe(OpcUaQuality.Good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_translates_OPC_UA_status_severity_bits_to_OpcUaQuality()
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
||||
|
||||
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
|
||||
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// Uncertain — severity bits 01 (top 2 bits = 01).
|
||||
driver.FireDataChange("tag-1", value: 1, statusCode: 0x40000000u);
|
||||
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Uncertain);
|
||||
|
||||
// Bad — severity bits 10.
|
||||
driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u);
|
||||
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().Quality.ShouldBe(OpcUaQuality.Bad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_against_non_ISubscribable_replies_with_failure()
|
||||
{
|
||||
var driver = new StubDriver(); // IDriver only
|
||||
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
||||
|
||||
var reply = await actor.Ask<DriverInstanceActor.SubscriptionFailed>(
|
||||
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
reply.Reason.ShouldContain("ISubscribable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectObserved_detaches_subscription_handler_so_late_events_are_dropped()
|
||||
{
|
||||
var driver = new SubscribableStubDriver();
|
||||
var parent = CreateTestProbe();
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
||||
AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2));
|
||||
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
|
||||
new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend went away"));
|
||||
|
||||
// Race window — once disconnect is processed, subsequent FireDataChange calls hit a
|
||||
// detached handler and don't push anything to the parent.
|
||||
AwaitCondition(() => driver.OnDataChangeSubscriberCount == 0, TimeSpan.FromSeconds(2));
|
||||
driver.FireDataChange("tag-1", value: 99, statusCode: 0u);
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
private class StubDriver : IDriver
|
||||
{
|
||||
public bool InitializeShouldThrow { get; set; }
|
||||
public int InitializeCount;
|
||||
@@ -88,4 +210,45 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class WritableStubDriver : StubDriver, IWritable
|
||||
{
|
||||
public uint NextStatusCode { get; set; } = 0u;
|
||||
public List<WriteRequest> Writes { get; } = new();
|
||||
|
||||
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
Writes.AddRange(writes);
|
||||
IReadOnlyList<WriteResult> results = writes.Select(_ => new WriteResult(NextStatusCode)).ToList();
|
||||
return Task.FromResult(results);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
||||
{
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
private readonly StubHandle _handle = new();
|
||||
|
||||
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<ISubscriptionHandle>(_handle);
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public void FireDataChange(string fullRef, object? value, uint statusCode)
|
||||
{
|
||||
var snapshot = new DataValueSnapshot(value, statusCode, DateTime.UtcNow, DateTime.UtcNow);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, snapshot));
|
||||
}
|
||||
|
||||
private sealed class StubHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "stub-sub";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user