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.