fix(abcip): report ALMD transitions with ConditionId = RawPath and a Kind (#641)
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
|
||||
|
||||
+21
@@ -104,6 +104,27 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 (item 4) — routing is an EXACT match on the RawPath: a driver that decorates its
|
||||
/// ConditionId (AbCip used to send <c>"{RawPath}#active"</c>, FOCAS sends <c>"{host}#{type}:{number}"</c>) has
|
||||
/// every transition dropped, so a native source MUST report <c>ConditionId = RawPath</c>.</summary>
|
||||
[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<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5)).State.Message.ShouldBe("plain");
|
||||
}
|
||||
|
||||
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
|
||||
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
|
||||
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
|
||||
|
||||
Reference in New Issue
Block a user