39 KiB
Galaxy Phase B — Native Alarms on the Equipment-Tag Path — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
Goal: A Galaxy equipment Tag marked as a native alarm (via its TagConfig JSON) materializes a real OPC UA Part 9 AlarmConditionState under its equipment folder, and the driver's live IAlarmSource.OnAlarmEvent transitions drive that condition (active/severity/message/ack) and fan out to the alerts topic — mirroring the scripted-alarm seam. No EF/schema migration.
Architecture: Three reused layers + one new seam. (1) Alarm intent rides in the schemaless TagConfig blob, parsed byte-parity in Phase7Composer + DeploymentArtifact (EquipmentTagPlan.Alarm). (2) Phase7Applier.MaterialiseEquipmentTags branches: alarm tag → the existing MaterialiseAlarmCondition (reused verbatim), else the existing EnsureVariable. (3) A new driver→server alarm seam: DriverInstanceActor subscribes IAlarmSource.OnAlarmEvent (mirroring its OnDataChange subscription) and publishes AttributeAlarmPublished to DriverHostActor, which projects each transition into the existing AlarmConditionSnapshot (NativeAlarmProjector) and Tells the unchanged OpcUaPublishActor.AlarmStateUpdate → OtOpcUaNodeManager.WriteAlarmCondition, plus a Primary-gated AlarmTransitionEvent to alerts. A small additive contract change adds the transition Kind to AlarmEventArgs (the driver already has it; the record's surviving consumers compile via a default).
Tech Stack: C#/.NET 10, Akka.NET (fused-host actors, Akka.TestKit.Xunit2 — xUnit v2, use CancellationToken.None not TestContext.Current), xUnit + Shouldly, OPC Foundation UA stack. No bUnit — Razor/live paths proven only by the user-driven docker-dev /run gate.
Design: docs/plans/2026-06-14-galaxy-phase-b-native-alarms-design.md (master 90096e9c).
Hard rules (every task): stage by path, never git add .; never stage sql_login.txt, src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/, pending.md, current.md, docker-dev/docker-compose.yml; never echo secrets; no force-push, no --no-verify; NO Configuration entity / EF migration change; no bUnit. Commit per task by path.
Task 0: Feature branch
Classification: trivial Estimated implement time: ~1 min Parallelizable with: none
Step 1: From master at 90096e9c:
cd /Users/dohertj2/Desktop/OtOpcUa
git checkout master && git rev-parse --short HEAD # expect 90096e9c
git checkout -b feat/galaxy-phase-b-native-alarms
No code change. Do NOT touch the working-tree docker-dev/docker-compose.yml or pending.md.
Task 1: Transition-kind contract (AlarmEventArgs.Kind) + Galaxy populates it — WS-1
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 2
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs(add enumAlarmTransitionKind; add trailingKindparam toAlarmEventArgs) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:~1128-1167(OnAlarmFeedTransitionmapstransition.TransitionKind→Kind) - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/(newAlarmEventArgsTests.cs) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/(extend the existing alarm-feed/transition test, or addGalaxyAlarmTransitionKindTests.cs)
Context: GalaxyAlarmTransition (Driver.Galaxy/Runtime/GalaxyAlarmTransition.cs) carries GalaxyAlarmTransitionKind {Unspecified=0, Raise=1, Acknowledge=2, Clear=3, Retrigger=4} but OnAlarmFeedTransition drops it when building AlarmEventArgs. Other IAlarmSource implementers (FOCAS/OpcUaClient/AbCip/ScriptedAlarmSource) construct AlarmEventArgs without a kind — a record default keeps them compiling untouched.
Step 1 (failing test — contract): In AlarmEventArgsTests.cs:
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
public class AlarmEventArgsTests
{
private static AlarmEventArgs Make(AlarmTransitionKind? kind = null) =>
kind is null
? new AlarmEventArgs(new FakeHandle(), "Tank1.Hi", "c1", "LimitAlarm.Hi", "msg", AlarmSeverity.High, DateTime.UnixEpoch)
: new AlarmEventArgs(new FakeHandle(), "Tank1.Hi", "c1", "LimitAlarm.Hi", "msg", AlarmSeverity.High, DateTime.UnixEpoch, Kind: kind.Value);
[Fact]
public void Kind_defaults_to_Unspecified_so_existing_callers_compile()
=> Make().Kind.ShouldBe(AlarmTransitionKind.Unspecified);
[Fact]
public void Kind_round_trips_when_supplied()
=> Make(AlarmTransitionKind.Raise).Kind.ShouldBe(AlarmTransitionKind.Raise);
private sealed class FakeHandle : IAlarmSubscriptionHandle { public string DiagnosticId => "t"; }
}
Run: dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter AlarmEventArgsTests → FAIL (no Kind, no AlarmTransitionKind).
Step 2 (implement contract): In IAlarmSource.cs, add the enum (next to AlarmSeverity) and the Kind param as the last parameter of AlarmEventArgs (after AlarmCategory):
/// <summary>The kind of alarm state change. Mirrors the driver-internal transition kinds so a
/// consumer can derive Part 9 active/ack state without a separate value subscription. Defaults to
/// <see cref="Unspecified"/> so existing <see cref="IAlarmSource"/> implementers compile unchanged.</summary>
public enum AlarmTransitionKind { Unspecified = 0, Raise, Acknowledge, Clear, Retrigger }
// …existing params…
string? AlarmCategory = null,
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified);
Add a /// <param name="Kind">…</param> doc line to the record's XML doc (the project sets TreatWarningsAsErrors — a missing <param> for a documented record is a build error).
Step 3 (failing test — Galaxy mapping): Confirm how the existing Galaxy tests feed a GalaxyAlarmTransition onto OnAlarmEvent (grep OnAlarmFeedTransition/OnAlarmEvent in tests/Drivers/.../Driver.Galaxy.Tests). Add a test that, for each GalaxyAlarmTransitionKind, the surfaced AlarmEventArgs.Kind equals the matching AlarmTransitionKind. If the test harness can't reach the private OnAlarmFeedTransition, extract a tiny internal static AlarmTransitionKind MapKind(GalaxyAlarmTransitionKind) and test that directly (mark Driver.Galaxy InternalsVisibleTo the test project if not already — grep for it first).
Run → FAIL.
Step 4 (implement Galaxy mapping): In GalaxyDriver.OnAlarmFeedTransition, add Kind: to the new AlarmEventArgs(...):
Kind: transition.TransitionKind switch
{
GalaxyAlarmTransitionKind.Raise => AlarmTransitionKind.Raise,
GalaxyAlarmTransitionKind.Acknowledge => AlarmTransitionKind.Acknowledge,
GalaxyAlarmTransitionKind.Clear => AlarmTransitionKind.Clear,
GalaxyAlarmTransitionKind.Retrigger => AlarmTransitionKind.Retrigger,
_ => AlarmTransitionKind.Unspecified,
});
Step 5: dotnet build src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions then run both test filters → PASS. Build the full solution (dotnet build ZB.MOM.WW.OtOpcUa.slnx) to confirm no other IAlarmSource implementer broke.
Step 6 (commit):
git add src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs \
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs \
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/AlarmEventArgsTests.cs \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/<the test file>
git commit -m "feat(alarms): carry transition Kind on AlarmEventArgs; Galaxy populates it (Phase B WS-1)"
Task 2: Alarm intent in TagConfig → EquipmentTagPlan.Alarm (byte-parity) — WS-2
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 1
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs(EquipmentTagPlanrecord:80-88; newEquipmentTagAlarmInfo;Select(...):323-331; newExtractTagAlarmnext toExtractTagFullName:432) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs(BuildEquipmentTagPlans:440-448; newExtractTagAlarmmirror next toExtractTagFullName:637) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/(newExtractTagAlarmTests.cs; extend the existing composer↔artifact parity test)
Context — the byte-parity invariant: Phase7Composer (compose from DB rows) and DeploymentArtifact.BuildEquipmentTagPlans (decode the deployment artifact JSON) MUST produce identical EquipmentTagPlans for the same input — there is an existing parity test (grep EquipmentTagPlan in OpcUaServer.Tests for the parity fixture). The alarm object rides in the same TagConfig blob FullName already does, so both sides parse it the same way. Absent/malformed alarm ⇒ null (plain variable). TagConfig shape:
{ "FullName": "TestMachine_002.HiAlarm", "alarm": { "alarmType": "OffNormalAlarm", "severity": 700 } }
alarmType default "AlarmCondition", severity default 500 (mirrors ScriptedAlarm defaults). Valid alarmType strings are whatever OtOpcUaNodeManager.CreateAlarmConditionOfType accepts (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); an unknown string falls back to the base type downstream — do not validate here.
Step 1 (failing test — ExtractTagAlarm): In ExtractTagAlarmTests.cs (test the composer's private helper via [InternalsVisibleTo] if present, else assert through ComposeAsync/the plan; prefer a thin internal static so both helpers can be unit-tested):
[Theory]
[InlineData("{\"FullName\":\"X.Y\"}", false, null, 0)] // no alarm ⇒ null
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{}}", true, "AlarmCondition", 500)] // defaults
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", true, "OffNormalAlarm", 700)]
[InlineData("not json", false, null, 0)] // malformed ⇒ null
[InlineData("{\"FullName\":\"X.Y\",\"alarm\":\"oops\"}", false, null, 0)] // wrong kind ⇒ null
public void ExtractTagAlarm_parses_or_returns_null(string cfg, bool present, string? type, int sev)
{
var info = Phase7Composer.ExtractTagAlarm(cfg); // make it internal static
if (!present) { info.ShouldBeNull(); return; }
info!.AlarmType.ShouldBe(type);
info.Severity.ShouldBe(sev);
}
Run → FAIL.
Step 2 (implement EquipmentTagAlarmInfo + EquipmentTagPlan.Alarm): In Phase7Composer.cs, next to EquipmentTagPlan:
/// <summary>Native-alarm intent parsed from an equipment tag's <c>TagConfig.alarm</c> object. Null ⇒
/// the tag is a plain value variable. <see cref="AlarmType"/> is an OPC UA Part 9 subtype string
/// (OffNormalAlarm/DiscreteAlarm/LimitAlarm/AlarmCondition); <see cref="Severity"/> is the 1..1000 scale.</summary>
public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity);
Add EquipmentTagAlarmInfo? Alarm as the last field of EquipmentTagPlan (after Writable). Update the record's XML doc with a <see cref="Alarm"/> sentence.
Step 3 (implement ExtractTagAlarm — composer): Next to ExtractTagFullName:
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!doc.RootElement.TryGetProperty("alarm", out var a) || a.ValueKind != JsonValueKind.Object) return null;
var type = a.TryGetProperty("alarmType", out var tEl) && tEl.ValueKind == JsonValueKind.String
? (tEl.GetString() ?? "AlarmCondition") : "AlarmCondition";
var sev = a.TryGetProperty("severity", out var sEl) && sEl.ValueKind == JsonValueKind.Number
? sEl.GetInt32() : 500;
return new EquipmentTagAlarmInfo(type, sev);
}
catch (JsonException) { return null; }
}
Wire it into the Select(...) at :331 (add after Writable:):
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
Alarm: ExtractTagAlarm(t.TagConfig)))
Step 4 (implement ExtractTagAlarm — artifact mirror, byte-identical): In DeploymentArtifact.cs, add a private ExtractTagAlarm(string? tagConfig) with the same body (it constructs the same EquipmentTagAlarmInfo — Runtime already references the assembly defining EquipmentTagPlan, so the type is in scope). Wire into BuildEquipmentTagPlans :448:
Writable: writable,
Alarm: ExtractTagAlarm(tagConfig)));
Step 5 (failing test — parity): Extend the existing composer↔artifact parity fixture with an alarm-bearing equipment tag (TagConfig carrying the alarm object) and assert the composed EquipmentTagPlan.Alarm equals the artifact-decoded one (record equality covers it). Run the parity test → it now exercises Alarm.
Step 6: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "ExtractTagAlarm|Parity" → PASS. Full build clean.
Step 7 (commit):
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/<parity fixture file>
git commit -m "feat(alarms): EquipmentTagPlan.Alarm parsed byte-parity from TagConfig (Phase B WS-2)"
Task 3: Materialize a condition node for an alarm tag — WS-3
Classification: standard Estimated implement time: ~3 min Parallelizable with: Task 4, Task 5
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs:186-193(MaterialiseEquipmentTagsvariable loop) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/(extend the Phase7Applier materialise tests)
Context: MaterialiseEquipmentTags currently calls SafeEnsureVariable(...) for every tag (:192). SafeMaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity) already exists (:309) — the same one scripted alarms use. An alarm tag becomes a condition node only (not also a variable). The condition NodeId = the tag's folder-scoped NodeId (EquipmentNodeIds.Variable(...), same formula). Use the sub-folder as the condition's parent when FolderPath is set (the variable loop already computes parent).
Step 1 (failing test): With the existing test sink (grep the materialise tests for the fake/recording IOpcUaAddressSpaceSink), feed a composition whose EquipmentTags contains one plain tag and one Alarm != null tag; assert the plain one called EnsureVariable and the alarm one called MaterialiseAlarmCondition (with the matching nodeId/type/severity) and did not call EnsureVariable. Run → FAIL.
Step 2 (implement the branch): Replace the SafeEnsureVariable(...) call at :192 with:
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity);
}
else
{
SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, tag.Writable);
}
Step 3: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter MaterialiseEquipmentTags → PASS.
Step 4 (commit):
git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/<applier test file>
git commit -m "feat(alarms): materialise a Part 9 condition for an alarm equipment tag (Phase B WS-3)"
Task 4: NativeAlarmProjector (transition → snapshot) — WS-4a
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3, Task 5
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
Context: AlarmEventArgs is a delta (raise/clear/ack). AlarmConditionSnapshot (Commons/OpcUa/AlarmConditionSnapshot.cs: Active, Acknowledged, Confirmed, Enabled, Shelving, Severity(ushort), Message) is the full state WriteAlarmCondition wants. The projector keeps per-condition-NodeId prior (Active, Acked, Severity, Message) and derives the next snapshot from Kind. It lives in Runtime (references both Core.Abstractions and Commons). It is owned by the single-threaded DriverHostActor → a plain Dictionary is safe (no locking).
Severity map (AlarmSeverity 4-bucket → ushort 1..1000): Low→200, Medium→500, High→700, Critical→900.
Kind → snapshot:
| Kind | Active | Acknowledged | Severity/Message |
|---|---|---|---|
| Raise / Retrigger | true | false | from event |
| Acknowledge | prior | true | from event (keep prior severity if event is a pure ack — use event severity, fine) |
| Clear | false | prior | from event |
| Unspecified | prior | prior | from event |
Constant fields: Enabled=true, Confirmed=true, Shelving=Unshelved. |
Step 1 (failing tests):
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
public class NativeAlarmProjectorTests
{
private static AlarmEventArgs Evt(AlarmTransitionKind kind, AlarmSeverity sev = AlarmSeverity.High, string msg = "m")
=> new(new H(), "Tank1.Hi", "c1", "LimitAlarm.Hi", msg, sev, DateTime.UnixEpoch, Kind: kind);
[Fact]
public void Raise_is_active_and_unacked()
{
var s = new NativeAlarmProjector().Project("n1", Evt(AlarmTransitionKind.Raise));
s.Active.ShouldBeTrue(); s.Acknowledged.ShouldBeFalse();
s.Severity.ShouldBe((ushort)700); s.Enabled.ShouldBeTrue();
s.Shelving.ShouldBe(AlarmShelvingKind.Unshelved);
}
[Fact]
public void Acknowledge_sets_acked_and_keeps_prior_active()
{
var p = new NativeAlarmProjector();
p.Project("n1", Evt(AlarmTransitionKind.Raise));
var s = p.Project("n1", Evt(AlarmTransitionKind.Acknowledge));
s.Active.ShouldBeTrue(); s.Acknowledged.ShouldBeTrue();
}
[Fact]
public void Clear_deactivates_and_keeps_prior_ack()
{
var p = new NativeAlarmProjector();
p.Project("n1", Evt(AlarmTransitionKind.Raise));
var s = p.Project("n1", Evt(AlarmTransitionKind.Clear));
s.Active.ShouldBeFalse(); s.Acknowledged.ShouldBeFalse();
}
[Fact]
public void State_is_isolated_per_nodeId()
{
var p = new NativeAlarmProjector();
p.Project("n1", Evt(AlarmTransitionKind.Raise));
var s2 = p.Project("n2", Evt(AlarmTransitionKind.Clear)); // cold n2: clear from default-inactive
s2.Active.ShouldBeFalse();
}
private sealed class H : IAlarmSubscriptionHandle { public string DiagnosticId => "t"; }
}
Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter NativeAlarmProjector → FAIL.
Step 2 (implement):
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// <summary>
/// Derives a full Part 9 <see cref="AlarmConditionSnapshot"/> from each native
/// <see cref="AlarmEventArgs"/> delta, tracking per-condition-NodeId prior state. Owned by the
/// single-threaded <c>DriverHostActor</c> (no locking). Native alarms carry only a transition
/// <see cref="AlarmTransitionKind"/>, not a full state machine, so this is the translation the
/// scripted-alarm engine does internally.
/// </summary>
public sealed class NativeAlarmProjector
{
private readonly Dictionary<string, (bool Active, bool Acked, ushort Severity, string Message)> _prior =
new(StringComparer.Ordinal);
/// <summary>Project an alarm transition onto the full condition snapshot for <paramref name="nodeId"/>.</summary>
public AlarmConditionSnapshot Project(string nodeId, AlarmEventArgs e)
{
var prev = _prior.TryGetValue(nodeId, out var p) ? p : (Active: false, Acked: true, Severity: (ushort)0, Message: string.Empty);
var sev = MapSeverity(e.Severity);
var (active, acked) = e.Kind switch
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.Acknowledge => (prev.Active, true),
AlarmTransitionKind.Clear => (false, prev.Acked),
_ => (prev.Active, prev.Acked),
};
_prior[nodeId] = (active, acked, sev, e.Message);
return new AlarmConditionSnapshot(
Active: active, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: sev, Message: e.Message);
}
/// <summary>Clears tracked state (call on address-space rebuild).</summary>
public void Clear() => _prior.Clear();
private static ushort MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 200, AlarmSeverity.Medium => 500,
AlarmSeverity.High => 700, AlarmSeverity.Critical => 900, _ => 500,
};
}
Step 3: Run the filter → PASS.
Step 4 (commit):
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/NativeAlarmProjector.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/NativeAlarmProjectorTests.cs
git commit -m "feat(alarms): NativeAlarmProjector maps transitions to condition snapshots (Phase B WS-4a)"
Task 5: DriverInstanceActor subscribes OnAlarmEvent + publishes AttributeAlarmPublished — WS-4b
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 3, Task 4
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs(msg defs:63-66; new fields;AttachAlarmSource/DetachAlarmSource;Receive<NativeAlarmRaised>; wire attach on Connected-entry, detach inDetachSubscription+PostStop) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/(newDriverInstanceActorNativeAlarmTests.cs)
Context — mirror the OnDataChange pattern exactly (:408-409 attach, :452-460 DetachSubscription): the driver fires OnAlarmEvent on its own thread → marshal to the actor thread via self.Tell(...). Galaxy's alarm feed auto-starts in InitializeAsync and fires OnAlarmEvent independent of SubscribeAlarmsAsync, so the actor only subscribes the C# event (no SubscribeAlarmsAsync call — that's a deferred follow-up for drivers that gate on it). The server filters by SourceNodeId downstream (unknown refs drop), so forward every transition.
Step 1 (failing test, Akka.TestKit — xUnit v2):
// Fake driver implements IDriver + ISubscribable + IAlarmSource; exposes RaiseAlarm(args).
// Spawn the actor, drive it to Connected, then fakeDriver.RaiseAlarm(...) and
// ExpectMsg<DriverInstanceActor.AttributeAlarmPublished> on a probe wired as the parent.
// Use CancellationToken.None (NOT TestContext.Current — this is xUnit v2 / Akka.TestKit.Xunit2).
Model it on the existing DriverInstanceActor subscribe tests (grep AttributeValuePublished in Runtime.Tests/Drivers). Assert AttributeAlarmPublished.DriverInstanceId + .Args.SourceNodeId match. Run → FAIL.
Step 2 (implement messages): After AttributeValuePublished (:65):
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValuePublished"/>.</summary>
public sealed record AttributeAlarmPublished(string DriverInstanceId, AlarmEventArgs Args);
private sealed record NativeAlarmRaised(AlarmEventArgs Args);
Add fields near _dataChangeHandler:
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
Step 3 (implement attach/detach + receive):
/// <summary>Subscribe the driver's native alarm event (if it is an <see cref="IAlarmSource"/>),
/// marshaling each transition to the actor thread. Idempotent; mirrors the OnDataChange attach.</summary>
private void AttachAlarmSource()
{
if (_driver is not IAlarmSource src || _alarmEventHandler is not null) return;
var self = Self;
_alarmEventHandler = (_, e) => self.Tell(new NativeAlarmRaised(e));
src.OnAlarmEvent += _alarmEventHandler;
}
/// <summary>Symmetric teardown — called from <see cref="DetachSubscription"/> and PostStop so a stale
/// handler never pushes to a disconnected actor.</summary>
private void DetachAlarmSource()
{
if (_driver is IAlarmSource src && _alarmEventHandler is not null)
src.OnAlarmEvent -= _alarmEventHandler;
_alarmEventHandler = null;
}
Register in the Connected() behavior (next to Receive<DataChangeForward>(OnDataChangeForward) at :275):
Receive<NativeAlarmRaised>(m => Context.Parent.Tell(new AttributeAlarmPublished(_driverInstanceId, m.Args)));
Call AttachAlarmSource() on every Connected entry — the cleanest single site is right where ResubscribeDesired() is called after Become(Connected) (both the first-connect InitializeSucceeded path and the Reconnecting InitializeSucceeded at :295-302). Add AttachAlarmSource(); immediately after each Become(Connected);. Add DetachAlarmSource(); inside DetachSubscription() (:452, so it fires on Connected→Reconnecting + Unsubscribe + PostStop, which all route through it — verify PostStop calls DetachSubscription; if not, add DetachAlarmSource() to PostStop too).
Step 4: Run the filter → PASS. Confirm no double-attach (the _alarmEventHandler is not null guard) and that a teardown→reconnect re-attaches cleanly (add an assertion that a second Connected entry still delivers exactly one AttributeAlarmPublished per raise).
Step 5 (commit):
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs
git commit -m "feat(alarms): DriverInstanceActor forwards native OnAlarmEvent to parent (Phase B WS-4b)"
Task 6: DriverHostActor alarm map + ForwardNativeAlarm → live condition — WS-4c
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(new_alarmNodeIdByDriverRefmap +_nativeAlarmProjectorfield:99; build inPushDesiredSubscriptions:748-763, branching onAlarm != null;Receive<AttributeAlarmPublished>in the two behaviors that holdReceive<AttributeValuePublished>; newForwardNativeAlarm) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/(newDriverHostActorNativeAlarmTests.cs)
Context: ForwardToMux (:440-467) is the value analogue. The value maps (_nodeIdByDriverRef, _driverRefByNodeId) + refsByDriver (value subscription) are built in PushDesiredSubscriptions (:734-763). Alarm tags must be excluded from the value maps + value subscription (they're conditions, not variables) and routed via a parallel alarm map. An alarm's AlarmEventArgs.SourceNodeId equals the tag's FullName, so the alarm map keys on the same (DriverInstanceId, FullName).
Step 1 (failing test, Akka.TestKit): Spawn DriverHostActor with a wired _opcUaPublishActor probe; apply a composition (or call the SubscribeBulk path) containing one alarm-bearing EquipmentTag; send AttributeAlarmPublished(driverId, raiseEvent); ExpectMsg<OpcUaPublishActor.AlarmStateUpdate> at the alarm tag's NodeId with State.Active == true. Second test: an AttributeAlarmPublished for an unknown ref produces no AlarmStateUpdate. Model on the existing DriverHostActor value-routing tests (grep AttributeValueUpdate/_nodeIdByDriverRef in Runtime.Tests). Run → FAIL.
Step 2 (fields): At :99 add:
/// <summary>(DriverInstanceId, FullName=alarm SourceNodeId) → folder-scoped condition NodeId(s). Built
/// from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves native alarm
/// transitions to their materialised Part 9 condition node(s).</summary>
private readonly Dictionary<(string DriverInstanceId, string FullName), HashSet<string>> _alarmNodeIdByDriverRef = new();
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
Step 3 (build the map — PushDesiredSubscriptions): In the EquipmentTags loop (:755-763), branch so alarm tags go ONLY into _alarmNodeIdByDriverRef and are kept OUT of the value maps; and exclude alarm refs from refsByDriver (the value-subscription set at :734-741). Concretely:
- Add
_alarmNodeIdByDriverRef.Clear();next to the otherClear()s. - In the
foreach (var t in composition.EquipmentTags)loop:
var key = (t.DriverInstanceId, t.FullName);
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
if (t.Alarm is not null)
{
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal);
aset.Add(nodeId);
continue; // alarm tags are conditions, not value variables
}
// …existing value-map population (unchanged)…
- For
refsByDriver(:734-741), add.Where(t => t.Alarm is null)before theGroupByso the driver doesn't value-subscribe alarm attributes. - Reset projector state on rebuild: call
_nativeAlarmProjector.Clear();alongside the map clears (the condition nodes are torn down + rebuilt each apply, so prior state must not leak).
Step 4 (ForwardNativeAlarm + receives): Add the handler (model on ForwardToMux):
private void ForwardNativeAlarm(DriverInstanceActor.AttributeAlarmPublished msg)
{
if (_opcUaPublishActor is null) return;
if (!_alarmNodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.Args.SourceNodeId), out var nodeIds))
{
_log.Debug("DriverHost {Node}: no alarm condition for ({Driver},{Ref}) — transition dropped",
_localNode, msg.DriverInstanceId, msg.Args.SourceNodeId);
return;
}
foreach (var nodeId in nodeIds)
{
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc));
}
}
Register Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm); immediately after each Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux); (there are two — the steady + applying behaviors; grep ForwardToMux to find both registration sites).
Step 5: Run the filter → PASS. Full build clean.
Step 6 (commit):
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
git commit -m "feat(alarms): DriverHostActor routes native alarm transitions to Part 9 conditions (Phase B WS-4c)"
Task 7: Primary-gated AlarmTransitionEvent fan-out to alerts — WS-5
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(alarm-meta map; mediator publish inForwardNativeAlarm, Primary-gated via_localRole) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs(extend)
Context: ScriptedAlarmHostActor.OnEngineEmission (:289-315) publishes AlarmTransitionEvent to the alerts topic (consumed by HistorianAdapterActor + AdminUI /alerts), Primary-gated (suppress on Secondary/Detached). DriverHostActor already holds _localRole (:121, same gate it uses for RouteNodeWrite) and a DistributedPubSub mediator (it subscribes RedundancyStateTopic at :265). Reuse both. The event needs EquipmentPath/AlarmName/AlarmTypeName per condition node → add a small meta map built in the same PushDesiredSubscriptions pass. Grep AlertsTopic + AlarmTransitionEvent (Commons Messages/Alerts/) and ScriptedAlarmHostActor for the exact topic constant, the mediator field name, and the Publish usage to copy.
Step 1 (failing test): Drive the host to _localRole = Secondary (send the RedundancyStateChanged it consumes; grep the existing redundancy-gate test for RouteNodeWrite/"not primary" to copy the setup) → an AttributeAlarmPublished still Tells AlarmStateUpdate (condition stays warm) but publishes no AlarmTransitionEvent. With _localRole = Primary (or unset) → it publishes one. Run → FAIL.
Step 2 (implement meta map): Add private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType)> _alarmMetaByNodeId = new(StringComparer.Ordinal);; populate it in the alarm branch of the PushDesiredSubscriptions loop (Task 6 step 3): _alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType); and Clear() it with the others.
Step 3 (implement gated publish): In ForwardNativeAlarm, after the AlarmStateUpdate Tell (inside the foreach), append the Primary-gated publish, mirroring ScriptedAlarmHostActor:
if (_localRole is RedundancyRole.Secondary or RedundancyRole.Detached) continue; // warm-standby dedup
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m) ? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition");
_mediator.Tell(new Publish(AlertsTopic, new AlarmTransitionEvent(
AlarmId: nodeId,
EquipmentPath: meta.EquipmentId,
AlarmName: meta.Name,
TransitionKind: msg.Args.Kind.ToString(),
Severity: snapshot.Severity,
Message: msg.Args.Message,
User: msg.Args.OperatorComment is null ? string.Empty : "device",
TimestampUtc: msg.Args.SourceTimestampUtc,
AlarmTypeName: meta.AlarmType,
Comment: msg.Args.OperatorComment,
HistorizeToAveva: true)));
Use the exact AlarmTransitionEvent constructor argument list discovered by grep (adjust names/order if they differ; the historian + /alerts consumers already handle this shape from scripted alarms). Resolve the mediator handle the same way the existing redundancy subscription does (the field used at :265).
Step 4: Run the filter → PASS. Full build + dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests green.
Step 5 (commit):
git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorNativeAlarmTests.cs
git commit -m "feat(alarms): Primary-gated AlarmTransitionEvent fan-out for native alarms (Phase B WS-5)"
Task 8: Document the TagConfig alarm schema — Docs
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 3, Task 4, Task 5
Files:
- Modify:
docs/ScriptedAlarms.md(add a "Native driver alarms (equipment-tag path)" section) — ordocs/AlarmTracking.mdif that's the better home; pick whichever already covers condition materialization.
Step 1: Add a section documenting: a native driver alarm (Galaxy) is an authored equipment Tag whose TagConfig carries an alarm object — {"FullName":"tag.attr","alarm":{"alarmType":"OffNormalAlarm","severity":700}}; absent ⇒ plain variable; alarmType ∈ {AlarmCondition, OffNormalAlarm, DiscreteAlarm, LimitAlarm} (default AlarmCondition), severity 1..1000 (default 500). Note it materializes a Part 9 condition (not a variable) under the equipment folder, fed by the driver's live IAlarmSource.OnAlarmEvent; transitions fan out to /alerts + the historian (Primary-gated). State the two deferred follow-ups: inbound device-ack (client Acknowledge → AVEVA) and the AdminUI Galaxy-picker pre-fill.
Step 2 (commit):
git add docs/ScriptedAlarms.md
git commit -m "docs(alarms): native driver-alarm TagConfig schema (Phase B)"
Task 9: Live docker-dev /run verification (user-driven)
Classification: verification Estimated implement time: n/a (user drives; the agent does NOT sign in) Parallelizable with: none
Gate (the design's live-verify): On the live-gateway-backed MAIN-galaxy-eq (dev rig is LOCAL on this Mac — OrbStack; central-1 @ localhost:4840, deploy/AdminUI @ localhost:9200, sql @ localhost:14330; the Galaxy gateway needs the ephemeral GALAXY_MXGW_API_KEY re-exported on container recreate — see pending.md Galaxy dev-rig note):
- Rebuild central on the branch:
docker compose -f docker-dev/docker-compose.yml up -d --build migrator central-1 central-2(re-exportGALAXY_MXGW_API_KEY=…on the recreate). - Author a Galaxy alarm equipment tag on
EQ-55297329838dwhoseTagConfigcarries thealarmobject pointing at a real galaxy alarm reference (aTestMachine_002attribute with an alarm extension, or the seededTestMachine_001.TestAlarm00x). Deploy:POST http://localhost:9200/api/deployments, headerX-Api-Key: docker-dev-deploy-key. Order: deploy FIRST, then recreate central if the driver is faulted (a faulted driver ignoresApplyDelta). - Trip the Galaxy alarm. Confirm via Client.CLI
alarms/readagainstopc.tcp://localhost:4840that a Part 9AlarmConditionStateappears active under the equipment NodeId; confirm the AdminUI/alertsrow appears. Clear → condition goes inactive. - (Device-ack round-trip is the deferred follow-up — NOT part of this gate.)
On pass: finish via superpowers-extended-cc:finishing-a-development-branch (intent: merge-to-master + push). Update pending.md (disk-only) marking Phase B done; refresh the memory.
Execution order / dependency summary
T0 ─┬─ T1 (contract) ─┬─ T4 (projector) ─┐
│ └─ T5 (instance actor) ─┐
└─ T2 (plan field) ─┬─ T3 (applier) ├─ T6 (host live) ── T7 (host alerts) ── T9 (live)
└─ T8 (docs) ┘
- Parallel batch A (after T0): T1 ∥ T2.
- Parallel batch B (T1+T2 done): T3 ∥ T4 ∥ T5 ∥ T8 (all disjoint files).
- Serial tail: T6 (needs T2+T4+T5) → T7 (same file) → T9.