From 64d2514e2abad9b7312f060479a036f1d1ff2215 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:08:47 -0400 Subject: [PATCH] fix(abcip): report ALMD transitions with ConditionId = RawPath and a Kind (#641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while verifying item 4 of #641. The host routes a native alarm transition on an exact ConditionId == RawPath match (DriverHostActor.ForwardNativeAlarm), but AbCipAlarmProjection emitted "{ref}#active" and no Kind, so every ALMD raise/clear was dropped as "no alarm condition" — and even if routed, an Unspecified kind projects no state change. It now reports the subscribed reference (the RawPath) and Raise/Clear. A host test pins that a decorated id is dropped. FOCAS has the same id shape plus a device filter keyed by host address (a RawPath subscription polls nothing); it needs a mapping decision and is documented in docs/drivers/FOCAS.md rather than patched here. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/AbCip.md | 4 ++++ docs/drivers/FOCAS.md | 7 +++++++ .../AbCipAlarmProjection.cs | 14 +++++++++---- .../AbCipAlarmProjectionTests.cs | 15 +++++++++++++ .../DriverHostActorNativeAlarmTests.cs | 21 +++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index c71dcfe7..276a1735 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -264,6 +264,10 @@ analog alarms are a follow-up) and **disabled by default** — shops running FT Alarm & Events should keep it off and take alarms through the native route, since the projection semantics don't exactly mirror Rockwell FT A&E. +Each event carries `ConditionId` = the subscribed reference (the alarm tag's RawPath — the key the host +routes native transitions on) and `Kind` = `Raise` / `Clear`. Before Gitea #641 the projection sent +`"{ref}#active"` with no kind, so the host dropped every ALMD transition as unrouted. + ## Testing - **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes. diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md index f2442d1b..305d6444 100644 --- a/docs/drivers/FOCAS.md +++ b/docs/drivers/FOCAS.md @@ -294,6 +294,13 @@ Every alarm transition fires `OnAlarmEvent` with: Cleared alarms fire a second event with `" (cleared)"` appended to the message so downstream consumers can ignore the clear if they only care about raises. +> ⚠️ **Not routable to a v3 raw condition (found under Gitea #641).** The server routes a native transition +> only when `ConditionId` equals an alarm-bearing raw tag's RawPath, and the host subscribes those RawPaths — +> but this projection filters devices by **host address** (so a RawPath list matches no device and nothing is +> polled) and emits `"{host}#{Type}:{AlarmNumber}"` with no `Kind`. FOCAS native alarms therefore reach no +> Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device, +> not one condition per tag), so it is tracked separately rather than patched. + ## Handle recycling FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs index 83629420..ac429441 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs @@ -193,23 +193,29 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false); sub.LastInFaulted[nodeId] = nowFaulted; + // ConditionId is the subscribed reference itself — the alarm tag's RawPath, the key the host routes + // native transitions on — and Kind tells the host's projector what changed (Gitea #641). This used + // to send "{ref}#active" with no Kind, so the host dropped every ALMD transition as unrouted (and an + // Unspecified kind would have projected no state change anyway). if (!wasFaulted && nowFaulted) { _driver.InvokeAlarmEvent(new AlarmEventArgs( - sub.Handle, nodeId, ConditionId: $"{nodeId}#active", + sub.Handle, nodeId, ConditionId: nodeId, AlarmType: "ALMD", Message: $"ALMD {nodeId} raised", Severity: MapSeverity(severity), - SourceTimestampUtc: DateTime.UtcNow)); + SourceTimestampUtc: DateTime.UtcNow, + Kind: AlarmTransitionKind.Raise)); } else if (wasFaulted && !nowFaulted) { _driver.InvokeAlarmEvent(new AlarmEventArgs( - sub.Handle, nodeId, ConditionId: $"{nodeId}#active", + sub.Handle, nodeId, ConditionId: nodeId, AlarmType: "ALMD", Message: $"ALMD {nodeId} cleared", Severity: MapSeverity(severity), - SourceTimestampUtc: DateTime.UtcNow)); + SourceTimestampUtc: DateTime.UtcNow, + Kind: AlarmTransitionKind.Clear)); } } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs index 808b3043..558554f5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlarmProjectionTests.cs @@ -189,6 +189,21 @@ public sealed class AbCipAlarmProjectionTests await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None); await drv.ShutdownAsync(CancellationToken.None); + + // Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and + // projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every + // ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if + // it had routed. + AlarmEventArgs raised, cleared; + lock (events) + { + raised = events.First(e => e.Message.Contains("raised")); + cleared = events.First(e => e.Message.Contains("cleared")); + } + raised.ConditionId.ShouldBe("HighTemp"); + raised.Kind.ShouldBe(AlarmTransitionKind.Raise); + cleared.ConditionId.ShouldBe("HighTemp"); + cleared.Kind.ShouldBe(AlarmTransitionKind.Clear); } /// Verifies that unsubscribing stops the alarm poll loop. 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 2a2fadaa..1ac1b060 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 @@ -104,6 +104,27 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } + /// Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its + /// ConditionId (AbCip used to send "{RawPath}#active", FOCAS sends "{host}#{type}:{number}") has + /// every transition dropped, so a native source MUST report ConditionId = RawPath. + [Fact] + public void A_decorated_condition_id_is_dropped() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi"); + var (actor, publish) = SpawnHostAndApply(db, deploymentId); + + actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( + new StubAlarmHandle(), AlarmRawPath, $"{AlarmRawPath}#active", "ALMD", "decorated", AlarmSeverity.High, Ts, + Kind: AlarmTransitionKind.Raise))); + // Positive control: the undecorated id routes, and is the first update. + actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs( + new StubAlarmHandle(), AlarmRawPath, AlarmRawPath, "ALMD", "plain", AlarmSeverity.High, Ts, + Kind: AlarmTransitionKind.Raise))); + + publish.ExpectMsg(TimeSpan.FromSeconds(5)).State.Message.ShouldBe("plain"); + } + /// Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one /// to the cluster alerts topic with AlarmId = the raw /// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.