diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index e62c8d2d..b287ca3c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -2018,6 +2018,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS return; } + // Gitea #641 review — the node is already acknowledged (the SDK applies the client's ack); tell the + // projector too, or the next driver Clear projects (Active=false, Acked=prev=false) and un-acknowledges it. + // Most sources (MTConnect, FOCAS) send no Acknowledge transition back, and the projector now survives a + // deploy rather than resetting to its acked default. + _nativeAlarmProjector.MarkAcknowledged(msg.ConditionNodeId); + if (!_children.TryGetValue(target.DriverInstanceId, out var entry)) { _log.Debug("DriverHost {Node}: driver {Driver} not running for alarm ack of {Cond} — dropped", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs index fb0f8075..9e10f29b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs @@ -40,6 +40,17 @@ public sealed class NativeAlarmProjector StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good); } + /// + /// Gitea #641 review — record a client acknowledgement the node manager applied to + /// and routed toward the driver. The projector otherwise never sees it, and a later Clear would project the + /// stale unacknowledged prior. An untracked condition already defaults to acknowledged. + /// + /// The acknowledged condition node id. + public void MarkAcknowledged(string nodeId) + { + if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true); + } + /// Clears all tracked per-node state. public void Clear() => _prior.Clear(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs index 1ac1b060..36701991 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs @@ -473,6 +473,33 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase cleared.State.Acknowledged.ShouldBeFalse("nobody acknowledged it"); } + /// + /// Gitea #641 review — a CLIENT acknowledgement is applied to the node by the SDK and only ROUTED here; the + /// projector must learn of it. Before #641 the projector was cleared on every deploy, so a Clear arriving + /// after one defaulted Acked to true and happened to be right for a client-acked alarm; now that the + /// projector survives the deploy, a projector that never heard the ack projects the Clear as UNacked and + /// un-acknowledges the alarm the operator acknowledged. + /// + [Fact] + public void A_clear_after_a_client_acknowledge_and_a_redeploy_stays_acknowledged() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); + var (actor, publish, coordinator) = SpawnHostAndApplyWithCoordinator(db, deploymentId); + + actor.Tell(Transition(AlarmTransitionKind.Raise)); + publish.ExpectMsg(TimeSpan.FromSeconds(5)).State.Acknowledged.ShouldBeFalse(); + + // The client's Acknowledge on the node, routed toward the driver (no transition comes back for it). + actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "seen", "op")); + Redeploy(db, actor, publish, coordinator); + + actor.Tell(Transition(AlarmTransitionKind.Clear)); + var cleared = publish.FishForMessage(_ => true, TimeSpan.FromSeconds(5)); + cleared.State.Active.ShouldBeFalse(); + cleared.State.Acknowledged.ShouldBeTrue("the operator acknowledged it before it cleared"); + } + private static DriverInstanceActor.AttributeAlarmPublished Transition(AlarmTransitionKind kind) => new("drv-1", new AlarmEventArgs( new StubAlarmHandle(), "Temp", AlarmRawPath, "OffNormalAlarm", kind.ToString(), AlarmSeverity.High, Ts,