From b623fdb05e9e7fad4a998e5ee6fbde329ef4222b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 10 Sep 2026 15:27:00 -0400 Subject: [PATCH] feat(scripts): reset-aware Attributes.WriteBatchAndWaitAsync overload + deploy-gate mirror (#36) Claude-Session: https://claude.ai/code/session_01XPsuy2ZZ5Gbyfpt8PrK5BV --- .../ScriptCompileSurface.cs | 13 +++++ .../Scripts/ScopeAccessors.cs | 41 ++++++++++++++++ .../Scripts/ScopeAccessorTests.cs | 47 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs index 8055f80f..acce7a3c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs @@ -332,6 +332,19 @@ public sealed class ScriptCompileSurface /// The maximum time to wait for the response. /// true if the response value was observed before the timeout elapsed; otherwise false. public Task WriteBatchAndWaitAsync(IReadOnlyDictionary values, string flagKey, object? flagValue, string responseKey, object? responseValue, TimeSpan timeout) => throw new NotSupportedException(CompileOnly); + + /// Mirrors the reset-aware AttributeAccessor.WriteBatchAndWaitAsync overload (Gitea #36). + /// The set of attribute values to write as a batch. + /// The name of the flag attribute signaling the write. + /// The value written to the flag attribute. + /// The name of the attribute to observe for a response. + /// The value to wait for on the response attribute. + /// The maximum time to wait for the response. + /// The attribute written first and waited on before the batch. + /// The reset value the device must echo before the batch is written. + /// The maximum time to wait for the reset echo; null means . + /// The phase-aware handshake outcome. + public Task WriteBatchAndWaitAsync(IReadOnlyDictionary values, string flagKey, object? flagValue, string responseKey, object? responseValue, TimeSpan timeout, string resetKey, object? resetValue, TimeSpan? resetTimeout = null) => throw new NotSupportedException(CompileOnly); } /// Compile-only mirror of ChildrenAccessor. diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs index da523a5f..c581a5ec 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs @@ -111,6 +111,47 @@ public class AttributeAccessor timeout); } + /// + /// Reset-aware handshake (Gitea #36): writes =, + /// waits (event-driven, bounded by , default = ) + /// for the device to ECHO that reset, and only then performs the ordinary + /// + /// legs (batch → trigger → response wait). Returns a so a + /// script can tell a reset-echo timeout (; nothing + /// else was written) from a completion timeout. Use this for every level-triggered + /// flag handshake: without an observed reset the plain overload can be satisfied + /// instantly by the previous run's cached response value. Throws + /// if the reset write or the batch/trigger + /// write itself fails; never throws on either timeout. + /// + /// Attribute key → value to batch-write (data-sourced, one connection). + /// Trigger attribute key written AFTER the batch. + /// Value to write to the trigger. + /// Attribute key to wait on after the trigger. + /// Target value to wait for (null ⇒ any change). + /// How long to wait for the response before giving up. + /// Attribute written FIRST (usually ); static or data-sourced. + /// Value the device must echo back before the batch is written. + /// How long to wait for the reset echo; null ⇒ . + /// The phase-aware handshake outcome. + public Task WriteBatchAndWaitAsync( + IReadOnlyDictionary values, string flagKey, object? flagValue, + string responseKey, object? responseValue, TimeSpan timeout, + string resetKey, object? resetValue, TimeSpan? resetTimeout = null) + { + var encoded = new Dictionary(values.Count); + foreach (var kv in values) + encoded[Resolve(kv.Key)] = AttributeValueCodec.Encode(kv.Value); + + return _ctx.WriteBatchResetAndWait( + encoded, + Resolve(flagKey), AttributeValueCodec.Encode(flagValue), + Resolve(responseKey), AttributeValueCodec.Encode(responseValue), + timeout, + Resolve(resetKey), AttributeValueCodec.Encode(resetValue), + resetTimeout ?? timeout); + } + /// /// WaitForAttribute (spec §3-§5): waits event-driven until the attribute equals /// (value-equality, codec-normalized), bounded by diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs index 6d885f2b..03b9846d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScopeAccessorTests.cs @@ -294,4 +294,51 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable Assert.True(result.TimedOut); Assert.Null(result.Value); } + + // ── Reset-aware WriteBatchAndWaitAsync overload (Gitea #36) ────────────── + + /// + /// The reset-aware overload must scope-resolve and codec-encode the reset key/value + /// exactly like every other accessor, must default resetTimeout to + /// timeout, and — when the reset echo never arrives — must return a + /// result WITHOUT writing the batch or + /// the trigger (the ExpectNoMsg at the end is that assertion). + /// + [Fact] + public async Task WriteBatchAndWaitAsync_ResetAware_ResolvesScopeAndDefaultsResetTimeout() + { + var probe = CreateTestProbe(); + var ctx = MakeContext(probe.Ref); + + // Composed scope "RightMESReceiver" — every key is prefixed before it leaves the accessor. + var acc = new AttributeAccessor(ctx, "RightMESReceiver"); + + var call = acc.WriteBatchAndWaitAsync( + new Dictionary { ["MoveOutBatchID"] = 28950 }, + "MoveOutFlag", true, "MoveOutCompleteFlag", true, TimeSpan.FromSeconds(25), + "MoveOutCompleteFlag", false); + + // Leg 1 — the reset write. + var reset = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", reset.AttributeName); + Assert.Equal(AttributeValueCodec.Encode(false), reset.Value); + probe.Reply(new SetStaticAttributeResponse( + reset.CorrelationId, "Pump1", reset.AttributeName, true, null, DateTimeOffset.UtcNow)); + + // Leg 2 — the reset echo wait, bounded by the defaulted resetTimeout. + var resetWait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("RightMESReceiver.MoveOutCompleteFlag", resetWait.AttributeName); + Assert.Equal(AttributeValueCodec.Encode(false), resetWait.TargetValueEncoded); + Assert.Equal(TimeSpan.FromSeconds(25), resetWait.Timeout); // defaulted to `timeout` + probe.Reply(new WaitForAttributeResponse( + resetWait.CorrelationId, Matched: false, Value: null, Quality: null, TimedOut: true)); + + var result = await call; + Assert.True(result.ResetFailed); + Assert.False(result.Matched); + Assert.True(result.TimedOut); + + // Nothing else was written: no batch, no trigger. + probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + } }