diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs
index f308a732..0798bbc5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/InboundApi/RouteToInstanceRequest.cs
@@ -83,3 +83,46 @@ public record RouteToSetAttributesResponse(
bool Success,
string? ErrorMessage,
DateTimeOffset Timestamp);
+
+///
+/// Request to block until a remote instance attribute reaches a target value
+/// (spec §6 — Route.To("inst").WaitForAttribute(name, targetValue, timeout)).
+/// Value-equality ONLY across the wire: carries the
+/// canonical AttributeValueCodec-encoded target; there is no predicate and no
+/// quality flag in the comparison. The site evaluates equality and either matches or
+/// times out.
+///
+///
+/// Audit Log #23 (ParentExecutionId): mirrors .
+/// For an inbound-API-routed wait this is the inbound request's per-request execution id;
+/// future site-side audit emission for routed waits can stamp it as ParentExecutionId
+/// so the inbound→site execution-tree link survives the wait path. Additive trailing
+/// member — null for the Central UI sandbox path or for callers built before the field existed.
+///
+public record RouteToWaitForAttributeRequest(
+ string CorrelationId,
+ string InstanceUniqueName,
+ string AttributeName,
+ string? TargetValueEncoded,
+ TimeSpan Timeout,
+ DateTimeOffset Timestamp,
+ Guid? ParentExecutionId = null);
+
+///
+/// Response from a remote attribute wait. /
+/// convey the routing-level outcome (e.g. instance-not-found); ,
+/// , , and convey the wait
+/// outcome itself. When is true, exactly one of
+/// / holds: means the
+/// attribute reached the target value (with /
+/// captured at the match), means the deadline elapsed first.
+///
+public record RouteToWaitForAttributeResponse(
+ string CorrelationId,
+ bool Matched,
+ object? Value,
+ string? Quality,
+ bool TimedOut,
+ bool Success,
+ string? ErrorMessage,
+ DateTimeOffset Timestamp);
diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
index 7cec3a3e..3b866e35 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs
@@ -445,6 +445,25 @@ public class CommunicationService
envelope, _options.IntegrationTimeout, cancellationToken);
}
+ ///
+ /// Routes an inbound API wait-for-attribute request to a site (spec §6).
+ ///
+ /// The target site identifier.
+ /// The wait-for-attribute route request.
+ /// Cancellation token.
+ /// The wait-for-attribute route response.
+ public async Task RouteToWaitForAttributeAsync(
+ string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken = default)
+ {
+ var envelope = new SiteEnvelope(siteId, request);
+ // A wait legitimately blocks up to request.Timeout on the site, so the cluster
+ // Ask must be bounded by the WAIT deadline (plus integration-timeout slack for
+ // the round trip), not the generic IntegrationTimeout used by the other routes.
+ var askTimeout = request.Timeout + _options.IntegrationTimeout;
+ return await GetActor().Ask(
+ envelope, askTimeout, cancellationToken);
+ }
+
// ── Notification Outbox (central-local actor — Asked directly, no SiteEnvelope) ──
///
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs
index 01e001e1..d306ec33 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/CommunicationServiceInstanceRouter.cs
@@ -35,4 +35,9 @@ public sealed class CommunicationServiceInstanceRouter : IInstanceRouter
public Task RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) =>
_communicationService.RouteToSetAttributesAsync(siteId, request, cancellationToken);
+
+ ///
+ public Task RouteToWaitForAttributeAsync(
+ string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken) =>
+ _communicationService.RouteToWaitForAttributeAsync(siteId, request, cancellationToken);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs
index 1d08d123..65a00535 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/IInstanceRouter.cs
@@ -34,4 +34,12 @@ public interface IInstanceRouter
/// A task that resolves to the set-attributes response from the target site.
Task RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken);
+
+ /// Routes a wait-for-attribute request to the specified site (spec §6).
+ /// Target site identifier.
+ /// The wait-for-attribute request to route (value-equality only).
+ /// Cancellation token for the routed call.
+ /// A task that resolves to the wait-for-attribute response from the target site.
+ Task RouteToWaitForAttributeAsync(
+ string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs
index 76c02a97..b335f232 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs
@@ -205,6 +205,47 @@ public class RouteTarget
return response.Values;
}
+ ///
+ /// Blocks until a remote instance attribute reaches
+ /// or elapses (spec §6). Value-equality ONLY across the
+ /// wire: the target is canonically encoded via and
+ /// the site evaluates equality — there is no predicate and no quality flag in the
+ /// comparison.
+ ///
+ /// Name of the attribute to wait on.
+ /// Target value the attribute must equal for the wait to match.
+ /// Maximum time to wait for the attribute to reach the target value.
+ /// Optional cancellation token; defaults to the method deadline.
+ /// A task that resolves to true if the attribute reached the target value, false if the wait timed out.
+ public async Task WaitForAttribute(
+ string attributeName,
+ object? targetValue,
+ TimeSpan timeout,
+ CancellationToken cancellationToken = default)
+ {
+ var token = Effective(cancellationToken);
+ var siteId = await ResolveSiteAsync(token);
+
+ // Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
+ // spawning inbound request's ExecutionId so future site-side audit
+ // emission for routed waits can record this wait's parent. CorrelationId
+ // is the per-operation lifecycle id, freshly minted per routed wait.
+ var request = new RouteToWaitForAttributeRequest(
+ Guid.NewGuid().ToString(), _instanceCode, attributeName,
+ AttributeValueCodec.Encode(targetValue), timeout, DateTimeOffset.UtcNow,
+ _parentExecutionId);
+
+ var response = await _instanceRouter.RouteToWaitForAttributeAsync(siteId, request, token);
+
+ if (!response.Success)
+ {
+ throw new InvalidOperationException(
+ response.ErrorMessage ?? "Remote attribute wait failed");
+ }
+
+ return response.Matched;
+ }
+
///
/// Sets a single attribute value on the remote instance.
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs
index 99459613..1b21ffec 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs
@@ -623,5 +623,11 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
+
+ // WaitForAttribute is not part of this fixture's routed-Call audit scenario;
+ // mirror the other non-Call methods (unexercised here).
+ public Task RouteToWaitForAttributeAsync(
+ string siteId, RouteToWaitForAttributeRequest request, CancellationToken cancellationToken)
+ => throw new NotSupportedException();
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs
index 4abcf17a..a812f205 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/RouteHelperTests.cs
@@ -1,6 +1,7 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -139,6 +140,116 @@ public class RouteHelperTests
Assert.Equal("read failed", ex.Message);
}
+ // --- WaitForAttribute (spec §6) ---
+
+ [Fact]
+ public async Task WaitForAttribute_Matched_ReturnsTrue()
+ {
+ SiteResolves("inst-1", "SiteA");
+ _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Any())
+ .Returns(ci => new RouteToWaitForAttributeResponse(
+ ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
+ Matched: true, Value: true, Quality: "Good", TimedOut: false,
+ Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
+
+ var matched = await CreateHelper().To("inst-1")
+ .WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
+
+ Assert.True(matched);
+ }
+
+ [Fact]
+ public async Task WaitForAttribute_TimedOut_ReturnsFalse()
+ {
+ SiteResolves("inst-1", "SiteA");
+ _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Any())
+ .Returns(ci => new RouteToWaitForAttributeResponse(
+ ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
+ Matched: false, Value: null, Quality: null, TimedOut: true,
+ Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
+
+ var matched = await CreateHelper().To("inst-1")
+ .WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
+
+ Assert.False(matched);
+ }
+
+ [Fact]
+ public async Task WaitForAttribute_RoutingFailure_ThrowsInvalidOperationException()
+ {
+ // Success=false is a routing-level outcome (e.g. instance not found on the
+ // site), distinct from the wait outcome (Matched/TimedOut).
+ SiteResolves("inst-1", "SiteA");
+ _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Any())
+ .Returns(ci => new RouteToWaitForAttributeResponse(
+ ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
+ Matched: false, Value: null, Quality: null, TimedOut: false,
+ Success: false, ErrorMessage: "instance not found", DateTimeOffset.UtcNow));
+
+ var ex = await Assert.ThrowsAsync(
+ () => CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30)));
+ Assert.Equal("instance not found", ex.Message);
+ }
+
+ [Fact]
+ public async Task WaitForAttribute_EncodesTargetValue_OnRequest()
+ {
+ // Value-equality only across the wire: the target value is encoded via the
+ // canonical AttributeValueCodec, identical to how attribute values travel.
+ SiteResolves("inst-1", "SiteA");
+ RouteToWaitForAttributeRequest? captured = null;
+ _router.RouteToWaitForAttributeAsync("SiteA", Arg.Do(r => captured = r), Arg.Any())
+ .Returns(ci => new RouteToWaitForAttributeResponse(
+ ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
+ Matched: true, Value: true, Quality: "Good", TimedOut: false,
+ Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
+
+ await CreateHelper().To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
+
+ Assert.NotNull(captured);
+ Assert.Equal("Flag", captured!.AttributeName);
+ Assert.Equal(TimeSpan.FromSeconds(30), captured.Timeout);
+ Assert.Equal(AttributeValueCodec.Encode(true), captured.TargetValueEncoded);
+ Assert.True(Guid.TryParse(captured.CorrelationId, out _));
+ }
+
+ [Fact]
+ public async Task WaitForAttribute_WithNoExplicitToken_InheritsMethodDeadlineToken()
+ {
+ SiteResolves("inst-1", "SiteA");
+ using var deadline = new CancellationTokenSource();
+ CancellationToken seen = default;
+ _router.RouteToWaitForAttributeAsync("SiteA", Arg.Any(), Arg.Do(t => seen = t))
+ .Returns(ci => new RouteToWaitForAttributeResponse(
+ ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
+ Matched: false, Value: null, Quality: null, TimedOut: true,
+ Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
+
+ var bound = CreateHelper().WithDeadline(deadline.Token);
+ await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
+
+ Assert.Equal(deadline.Token, seen);
+ }
+
+ [Fact]
+ public async Task WaitForAttribute_WithParentExecutionId_CarriesItOnRequest()
+ {
+ SiteResolves("inst-1", "SiteA");
+ var inboundExecutionId = Guid.NewGuid();
+ RouteToWaitForAttributeRequest? captured = null;
+ _router.RouteToWaitForAttributeAsync("SiteA", Arg.Do(r => captured = r), Arg.Any())
+ .Returns(ci => new RouteToWaitForAttributeResponse(
+ ((RouteToWaitForAttributeRequest)ci[1]).CorrelationId,
+ Matched: true, Value: true, Quality: "Good", TimedOut: false,
+ Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
+
+ var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
+ await bound.To("inst-1").WaitForAttribute("Flag", true, TimeSpan.FromSeconds(30));
+
+ Assert.NotNull(captured);
+ Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
+ }
+
// --- SetAttribute(s) ---
[Fact]