diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 97293217..da9f817f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -964,6 +964,89 @@ public class ScriptRuntimeContext public async Task WriteBatchAndWait( IReadOnlyDictionary encodedValues, string triggerAttr, string? triggerEncoded, string responseAttr, string? responseEncoded, TimeSpan timeout) + => (await WriteBatchAndWaitCore(encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout) + .ConfigureAwait(false)).Matched; + + /// + /// Reset-aware handshake (Gitea #36, design 2026-09-10 §5) backing the nine-argument + /// Attributes.WriteBatchAndWaitAsync overload. Legs, in a FIXED order: + /// (1) write = through + /// (throws on a device-write failure, like the batch leg); + /// (2) wait event-driven for the device to ECHO the reset, bounded by + /// — not matched ⇒ return a + /// result and write NOTHING else; (3) the existing batch + trigger write; (4) the existing + /// response wait bounded by . + /// + /// + /// Why the reset must be OBSERVED before the trigger is written: a subscription reports a + /// change from the last reported value, so a device that flips reset→done inside one + /// sampling interval never reports the reset — the cached previous-run "done" value would + /// then satisfy the response wait instantly (the #36 race), and an edge-triggered wait + /// would never resolve at all. Holding the trigger until the echo lands makes the existing + /// level-triggered fast-path correct again. Each leg is already a traced Flow Debugger + /// step, so a traced run shows all four. + /// + /// Scope-resolved attribute name → codec-encoded value to batch-write. + /// Scope-resolved trigger attribute written AFTER the batch. + /// Codec-encoded trigger value. + /// Scope-resolved attribute to wait on after the trigger. + /// Codec-encoded response target (null ⇒ any change). + /// Bound for the response wait. + /// Scope-resolved attribute written FIRST (usually the response attribute). + /// Codec-encoded reset value the device must echo before anything else is written. + /// Bound for the reset-echo wait. + /// A phase-aware result; never throws on either timeout. + public async Task WriteBatchResetAndWait( + IReadOnlyDictionary encodedValues, string triggerAttr, string? triggerEncoded, + string responseAttr, string? responseEncoded, TimeSpan timeout, + string resetAttr, string? resetEncoded, TimeSpan resetTimeout) + { + // Leg 1 — reset write. A static attribute updates the cache synchronously (leg 2 then + // fast-paths); a data-sourced one goes to the device and the cache waits for the echo. + await SetAttribute(resetAttr, resetEncoded ?? string.Empty).ConfigureAwait(false); + + // Leg 2 — reset echo. Quality-agnostic, like the response wait. + var reset = await WaitInternal(resetAttr, resetEncoded, null, resetTimeout, requireGoodQuality: false) + .ConfigureAwait(false); + if (!reset.Matched) + { + return new HandshakeResult( + Matched: false, reset.Value, reset.Quality, reset.TimedOut, HandshakePhase.Reset, + reset.ErrorMessage + ?? (reset.TimedOut + ? $"Timed out after {resetTimeout.TotalSeconds}s waiting for '{resetAttr}' to echo the reset value" + : $"Reset wait for '{resetAttr}' did not match")); + } + + // Legs 3 + 4 — unchanged batch/trigger write and response wait. + var response = await WriteBatchAndWaitCore( + encodedValues, triggerAttr, triggerEncoded, responseAttr, responseEncoded, timeout).ConfigureAwait(false); + return new HandshakeResult( + response.Matched, response.Value, response.Quality, response.TimedOut, HandshakePhase.Response, + response.Matched + ? null + : response.ErrorMessage + ?? (response.TimedOut + ? $"Timed out after {timeout.TotalSeconds}s waiting for '{responseAttr}'" + : $"Response wait for '{responseAttr}' did not match")); + } + + /// + /// Shared body of / : + /// the batch + trigger Ask (throws on failure) followed by the response wait, returning + /// the full so the reset-aware caller can surface + /// value/quality/timeout. + /// + /// Scope-resolved attribute name → codec-encoded value to batch-write. + /// Scope-resolved trigger attribute name written AFTER the batch. + /// Codec-encoded value for the trigger. + /// Scope-resolved attribute to wait on. + /// Codec-encoded target value (null ⇒ any change). + /// How long to wait for the response before returning a timed-out result. + /// The full response wait result — never throws on a wait timeout. + private async Task WriteBatchAndWaitCore( + IReadOnlyDictionary encodedValues, string triggerAttr, string? triggerEncoded, + string responseAttr, string? responseEncoded, TimeSpan timeout) { var cid = Guid.NewGuid().ToString(); var batchReq = new WriteAttributeBatchRequest( @@ -1003,7 +1086,8 @@ public class ScriptRuntimeContext throw new InvalidOperationException( $"WriteBatchAndWait write failed: {batchResp.ErrorMessage}"); - return await WaitAttribute(responseAttr, responseEncoded, null, timeout).ConfigureAwait(false); + return await WaitInternal(responseAttr, responseEncoded, null, timeout, requireGoodQuality: false) + .ConfigureAwait(false); } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs new file mode 100644 index 00000000..b206ae24 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/WriteBatchResetAndWaitTests.cs @@ -0,0 +1,164 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; + +/// +/// Gitea #36 / design 2026-09-10 §5: the reset-aware handshake runs its legs in a FIXED +/// order — reset write, reset-echo wait, batch + trigger, response wait — and never writes +/// the batch unless the reset echo was observed. The probe stands in for the InstanceActor +/// so the order and the "nothing after a failed reset" property are directly observable. +/// +public class WriteBatchResetAndWaitTests : TestKit +{ + private const string Instance = "Plant.Reactor1"; + private static readonly IReadOnlyDictionary Values = + new Dictionary { ["BatchID"] = "28950" }; + + private static ScriptRuntimeContext CreateContext(IActorRef instanceActor) => + new(instanceActor, + new SharedScriptLibrary( + new ScriptCompilationService(NullLogger.Instance), + NullLogger.Instance), + currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), + instanceName: Instance, logger: NullLogger.Instance); + + [Fact] + public async Task Legs_RunInOrder_AndReportTheResponsePhase() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + // Leg 1: the reset write, and nothing else yet. + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutCompleteFlag", reset.AttributeName); + Assert.Equal("False", reset.Value); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); + + // Leg 2: the reset-echo wait targets the RESET value and the RESET timeout. + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutCompleteFlag", resetWait.AttributeName); + Assert.Equal("False", resetWait.TargetValueEncoded); + Assert.Equal(TimeSpan.FromSeconds(10), resetWait.Timeout); + Assert.False(resetWait.RequireGoodQuality); + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false)); + + // Leg 3: only now the batch + trigger. + var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutFlag", batch.TriggerAttribute); + Assert.Equal("True", batch.TriggerEncodedValue); + Assert.Equal("28950", batch.AttributeEncodedValues["BatchID"]); + probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); + + // Leg 4: the response wait with the RESPONSE timeout. + var respWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("MoveOutCompleteFlag", respWait.AttributeName); + Assert.Equal("True", respWait.TargetValueEncoded); + Assert.Equal(TimeSpan.FromSeconds(25), respWait.Timeout); + probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: true, true, "Good", TimedOut: false)); + + var result = await call; + Assert.True(result.Matched); + Assert.Equal(HandshakePhase.Response, result.Phase); + Assert.False(result.TimedOut); + Assert.False(result.ResetFailed); + Assert.Equal(true, result.Value); + Assert.Equal("Good", result.Quality); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task ResetEchoTimeout_ReportsResetPhase_AndNeverWritesTheBatch() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: false, null, null, TimedOut: true)); + + var result = await call; + Assert.False(result.Matched); + Assert.True(result.TimedOut); + Assert.Equal(HandshakePhase.Reset, result.Phase); + Assert.True(result.ResetFailed); + Assert.Contains("MoveOutCompleteFlag", result.ErrorMessage); + + // The property that fixes #36: no batch, no trigger, after a reset that was never echoed. + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public async Task ResponseTimeout_ReportsResponsePhase() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", true, null, DateTimeOffset.UtcNow)); + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(resetWait.CorrelationId, Matched: true, false, "Good", TimedOut: false)); + var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); + var respWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(respWait.CorrelationId, Matched: false, null, null, TimedOut: true)); + + var result = await call; + Assert.False(result.Matched); + Assert.True(result.TimedOut); + Assert.Equal(HandshakePhase.Response, result.Phase); + Assert.False(result.ResetFailed); + } + + [Fact] + public async Task ResetWriteFailure_Throws_LikeTheBatchLeg() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchResetAndWait( + Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", "False", TimeSpan.FromSeconds(10)); + + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new SetStaticAttributeResponse(reset.CorrelationId, Instance, "MoveOutCompleteFlag", false, "device rejected", DateTimeOffset.UtcNow)); + + var ex = await Assert.ThrowsAsync(() => call); + Assert.Contains("device rejected", ex.Message); + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } + + /// The bool overload is untouched: batch first, then the wait, no reset leg. + [Fact] + public async Task LegacyOverload_StillWritesTheBatchFirst() + { + var probe = CreateTestProbe(); + var ctx = CreateContext(probe.Ref); + + var call = ctx.WriteBatchAndWait(Values, "MoveOutFlag", "True", "MoveOutCompleteFlag", "True", TimeSpan.FromSeconds(25)); + + var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); + var wait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, true, "Good", TimedOut: false)); + + Assert.True(await call); + } +}