From c482cac110050e8784f7f659f737a2738e733dba Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 09:10:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(siteruntime):=20unpack=20routed=20RouteToW?= =?UTF-8?q?aitForAttributeRequest=20into=20InstanceActor=20(spec=20=C2=A76?= =?UTF-8?q?=20site=20half)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Actors/SiteCommunicationActor.cs | 1 + .../Actors/DeploymentManagerActor.cs | 40 +++++++++++++ .../Actors/DeploymentManagerActorTests.cs | 56 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs index d3641c52..8cab30db 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs @@ -144,6 +144,7 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers Receive(msg => _deploymentManagerProxy.Forward(msg)); Receive(msg => _deploymentManagerProxy.Forward(msg)); Receive(msg => _deploymentManagerProxy.Forward(msg)); + Receive(msg => _deploymentManagerProxy.Forward(msg)); // OPC UA Tag Browser (interactive design-time query) — forward to the // Deployment Manager singleton, which always lands on the active site diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index d412572e..dcceb092 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -149,6 +149,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(RouteInboundApiCall); Receive(RouteInboundApiGetAttributes); Receive(RouteInboundApiSetAttributes); + Receive(RouteInboundApiWaitForAttribute); // OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager. // BrowseNodeCommand is routed to this singleton (active node) by @@ -1014,6 +1015,45 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers }).PipeTo(sender); } + /// + /// Spec §6 (WD-2b): unpacks a routed + /// (inbound-API Route.To().WaitForAttribute()) into the deployed + /// Instance Actor's site-local and relays + /// the result back. Value-equality only across the wire — the predicate is null + /// and RequireGoodQuality is left at its default. The Ask is bounded by the + /// wait timeout plus slack (NOT a fixed 30s), since the wait legitimately blocks + /// for up to . + /// + private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request) + { + if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) + { + Sender.Tell(new RouteToWaitForAttributeResponse( + request.CorrelationId, false, null, null, false, + false, $"Instance '{request.InstanceUniqueName}' not found on this site.", + DateTimeOffset.UtcNow)); + return; + } + + var sender = Sender; + // Routed waits are value-equality only (predicate null); RequireGoodQuality left at default. + var inner = new WaitForAttributeRequest( + request.CorrelationId, request.InstanceUniqueName, request.AttributeName, + request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow); + + // Ask bounded by the WAIT timeout + slack — NOT a fixed 30s (the wait legitimately blocks up to request.Timeout). + instanceActor.Ask(inner, request.Timeout + TimeSpan.FromSeconds(5)) + .ContinueWith(t => t.IsCompletedSuccessfully + ? new RouteToWaitForAttributeResponse( + request.CorrelationId, t.Result.Matched, t.Result.Value, t.Result.Quality, t.Result.TimedOut, + true, null, DateTimeOffset.UtcNow) + : new RouteToWaitForAttributeResponse( + request.CorrelationId, false, null, null, false, + false, t.Exception?.GetBaseException().Message ?? "Attribute wait timed out", + DateTimeOffset.UtcNow)) + .PipeTo(sender); + } + /// /// Writes attribute values on a deployed instance for a Route.To().SetAttribute(s) /// call (or a central Test Run bound to the instance). Each write is Ask'd to the diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index 062592af..a6eb66eb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; +using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; @@ -389,6 +390,61 @@ public class DeploymentManagerActorTests : TestKit, IDisposable Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}"); } + // ── Spec §6 (WD-2b): routed RouteToWaitForAttributeRequest → InstanceActor ── + + [Fact] + public async Task RouteInboundApiWaitForAttribute_AttributeAlreadyAtTarget_RepliesMatched() + { + // A routed wait whose target equals the instance's current (static) + // attribute value must satisfy the InstanceActor fast-path and come back + // Success:true, Matched:true with the matched value/quality. + var actor = CreateDeploymentManager(); + await Task.Delay(500); // empty startup + + // MakeConfigJson seeds a scalar static attribute "TestAttr" = "42" (Good). + actor.Tell(new DeployInstanceCommand( + "dep-wait", "WaitPump", "sha256:wait", + MakeConfigJson("WaitPump"), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(1000); // let the InstanceActor spin up + load static attrs + + // Encode the target the same way the InstanceActor encodes the current + // value for its codec-equality match (value-equality only across the wire). + var encodedTarget = AttributeValueCodec.Encode("42"); + actor.Tell(new RouteToWaitForAttributeRequest( + "wait-corr-1", "WaitPump", "TestAttr", encodedTarget, + TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("wait-corr-1", response.CorrelationId); + Assert.True(response.Success, $"Routed wait failed: {response.ErrorMessage}"); + Assert.True(response.Matched, "Expected fast-path match (attribute already at target)."); + Assert.False(response.TimedOut); + Assert.Equal("42", response.Value); + Assert.Equal("Good", response.Quality); + } + + [Fact] + public async Task RouteInboundApiWaitForAttribute_UnknownInstance_RepliesNotFound() + { + // A routed wait for an instance that was never deployed to this site must + // come back Success:false with a not-found message (routing-level outcome), + // mirroring the other RouteTo* unknown-instance paths. + var actor = CreateDeploymentManager(); + await Task.Delay(500); + + actor.Tell(new RouteToWaitForAttributeRequest( + "wait-corr-2", "NeverDeployedWait", "TestAttr", + AttributeValueCodec.Encode("42"), TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("wait-corr-2", response.CorrelationId); + Assert.False(response.Success); + Assert.False(response.Matched); + Assert.NotNull(response.ErrorMessage); + Assert.Contains("not found", response.ErrorMessage!, StringComparison.OrdinalIgnoreCase); + } + // ── M2.11: Debug-view routing — unknown-instance not-found signal ── [Fact]