diff --git a/docs/plans/2026-05-29-native-alarms.md b/docs/plans/2026-05-29-native-alarms.md new file mode 100644 index 00000000..42267222 --- /dev/null +++ b/docs/plans/2026-05-29-native-alarms.md @@ -0,0 +1,1525 @@ +# Native OPC UA & MxAccess Gateway Alarms — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** Mirror native alarms from OPC UA Alarms & Conditions servers and the MxAccess Gateway into ScadaBridge as a read-only, unified A&C-style alarm state model (severity + active/acked/shelved/suppressed), discovered at runtime from instance-bound sources, kept site-local and shown live in the DebugView. + +**Architecture:** A new `NativeAlarmActor` (peer to the computed `AlarmActor` under `InstanceActor`) subscribes through a new DCL capability seam (`IAlarmSubscribableConnection`) implemented by the OPC UA and MxGateway adapters. The `DataConnectionActor` opens **one** alarm feed per connection and routes transitions to instances by source reference. State lives in site SQLite, streams to central over the (additively) enriched gRPC `AlarmStateUpdate`, and is seeded via the existing DebugView snapshot. No central tables; read-only (no ack-back). Design doc: `docs/plans/2026-05-29-native-alarms-design.md`. + +**Tech Stack:** C#/.NET 10, Akka.NET (TestKit, Become/Stash), EF Core + MS SQL (central config), Microsoft.Data.Sqlite (site state), gRPC/protobuf, Blazor Server, System.CommandLine (CLI), xUnit + NSubstitute + Akka.TestKit.Xunit2 + bUnit. + +**Conventions:** Messages/flattened types are `record`; entities are `class`. xUnit `[Fact]`, name `Method_Scenario_Expected`. Build with `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Commit after each task. TDD: failing test → verify fail → implement → verify pass → commit. + +**Critical gotcha (read before Task 18):** `src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto` is **not** auto-compiled — the `` include is commented out in the `.csproj` and generated `.cs` files are checked into `SiteStreamGrpc/`. Regen is a manual macOS-only step (toggle the include, `dotnet build`, copy generated files, re-comment). Task 18 documents it. + +--- + +## Task dependency overview + +``` +T1 ─┬─ T2 ─┬─ T17 (computed AlarmActor enrich) + │ ├─ T18 (proto) ── T19 (grpc mapping) ── T23 (DebugView) +T3 ─┼─ T10 (DCL actor) + ├─ T11 (OPC UA adapter) + └─ T12 (MxGateway adapter) +T4 ─┬─ T5 ── T6 ── T21 (mgmt handlers) + ├─ T7 (migration) + ├─ T8 ── T9 (validation) + └─ T20 ─┬─ T21 ── T26 (seed) + ├─ T22 (CLI) + ├─ T24 (template UI) + └─ T25 (instance UI) +T13, T14 ──┐ +T1,T2,T3,T4(Resolved),T13,T14 ── T15 (NativeAlarmActor) ── T16 (InstanceActor wiring) +(everything) ── T27 (docs) , T28 (integration/manual verify) +``` + +--- + +## Task 1: Commons alarm core types + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 3, Task 4 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AlarmKind.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AlarmShelveState.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AlarmTransitionKind.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/AlarmConditionState.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/NativeAlarmTransition.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Alarms/AlarmConditionStateTests.cs` + +**Step 1: Write the failing test** + +```csharp +// AlarmConditionStateTests.cs +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.Alarms; + +public class AlarmConditionStateTests +{ + [Fact] + public void AlarmConditionState_DefaultsAreNormalUnshelved() + { + var s = new AlarmConditionState( + Active: false, Acknowledged: true, Confirmed: null, + Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: 0); + + Assert.False(s.Active); + Assert.Equal(AlarmShelveState.Unshelved, s.Shelve); + Assert.Equal(0, s.Severity); + } + + [Fact] + public void NativeAlarmTransition_CarriesSourceAndCondition() + { + var t = new NativeAlarmTransition( + SourceReference: "Tank01.Level.HiHi", SourceObjectReference: "Tank01", + AlarmTypeName: "AnalogLimitAlarm.HiHi", Kind: AlarmTransitionKind.Raise, + Condition: new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), + Category: "Process", Description: "High level", Message: "level high", + OperatorUser: "", OperatorComment: "", + OriginalRaiseTime: null, TransitionTime: DateTimeOffset.UnixEpoch, + CurrentValue: "92.1", LimitValue: "90"); + + Assert.Equal("Tank01", t.SourceObjectReference); + Assert.Equal(800, t.Condition.Severity); + Assert.Equal(AlarmTransitionKind.Raise, t.Kind); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter AlarmConditionStateTests` +Expected: FAIL — types do not exist (compile error). + +**Step 3: Write minimal implementation** + +```csharp +// AlarmKind.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Enums +public enum AlarmKind { Computed, NativeOpcUa, NativeMxAccess } + +// AlarmShelveState.cs — same namespace +public enum AlarmShelveState { Unshelved, OneShotShelved, TimedShelved, PermanentShelved } + +// AlarmTransitionKind.cs — same namespace +public enum AlarmTransitionKind { Snapshot, SnapshotComplete, Raise, Acknowledge, Clear, Retrigger, StateChange } +``` + +```csharp +// AlarmConditionState.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +/// Orthogonal OPC UA Part 9 sub-conditions + severity. Read-only mirror of the source. +public record AlarmConditionState( + bool Active, + bool Acknowledged, + bool? Confirmed, + AlarmShelveState Shelve, + bool Suppressed, + int Severity); +``` + +```csharp +// NativeAlarmTransition.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +/// Protocol-neutral alarm transition emitted by an IAlarmSubscribableConnection adapter. +public record NativeAlarmTransition( + string SourceReference, + string SourceObjectReference, + string AlarmTypeName, + AlarmTransitionKind Kind, + AlarmConditionState Condition, + string Category, + string Description, + string Message, + string OperatorUser, + string OperatorComment, + DateTimeOffset? OriginalRaiseTime, + DateTimeOffset TransitionTime, + string CurrentValue, + string LimitValue); +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter AlarmConditionStateTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Types tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Alarms +git commit -m "feat(commons): native alarm core types (AlarmConditionState, NativeAlarmTransition, enums)" +``` + +--- + +## Task 2: Extend AlarmStateChanged + computed-default mapping + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 3, Task 4 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/AlarmConditionStateFactory.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs` + +**Context:** `AlarmStateChanged` already uses init-properties (`Level`, `Message`) so all additions are additive — existing positional callers compile unchanged. + +**Step 1: Write the failing test** + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +public class AlarmStateChangedEnrichmentTests +{ + [Fact] + public void Defaults_AreComputedKind_WithAutoAck() + { + var m = new AlarmStateChanged("inst", "HiAlarm", AlarmState.Active, 700, DateTimeOffset.UnixEpoch); + Assert.Equal(AlarmKind.Computed, m.Kind); + Assert.True(m.Condition.Acknowledged); // computed = auto-acked + Assert.Equal(700, m.Condition.Severity); // severity defaults to Priority + Assert.True(m.Condition.Active); // derived from State + Assert.Equal("", m.SourceReference); + } + + [Fact] + public void Factory_ForComputed_MapsPriorityAndState() + { + var c = AlarmConditionStateFactory.ForComputed(AlarmState.Normal, priority: 250); + Assert.False(c.Active); + Assert.True(c.Acknowledged); + Assert.Equal(250, c.Severity); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter AlarmStateChangedEnrichmentTests` +Expected: FAIL — `Kind`/`Condition` not defined. + +**Step 3: Write minimal implementation** + +Add `AlarmConditionStateFactory.cs` (namespace `...Types.Alarms`): + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +public static class AlarmConditionStateFactory +{ + /// Computed alarms are auto-acked, never shelved/suppressed; severity = priority. + public static AlarmConditionState ForComputed(AlarmState state, int priority) => + new(Active: state == AlarmState.Active, Acknowledged: true, Confirmed: null, + Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: priority); +} +``` + +In `AlarmStateChanged.cs`, add these init-properties to the record body (keep existing `Level`, `Message`): + +```csharp +public AlarmKind Kind { get; init; } = AlarmKind.Computed; + +private AlarmConditionState? _condition; +/// Unified A&C-style condition. Defaults to a computed mapping of State+Priority. +public AlarmConditionState Condition +{ + get => _condition ?? AlarmConditionStateFactory.ForComputed(State, Priority); + init => _condition = value; +} + +public string SourceReference { get; init; } = string.Empty; +public string AlarmTypeName { get; init; } = string.Empty; +public string Category { get; init; } = string.Empty; +public string OperatorUser { get; init; } = string.Empty; +public string OperatorComment { get; init; } = string.Empty; +public DateTimeOffset? OriginalRaiseTime { get; init; } +public string CurrentValue { get; init; } = string.Empty; +public string LimitValue { get; init; } = string.Empty; +``` + +Add `using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;` to the file. + +**Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter AlarmStateChangedEnrichmentTests` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Streaming/AlarmStateChanged.cs src/ZB.MOM.WW.ScadaBridge.Commons/Types/Alarms/AlarmConditionStateFactory.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/AlarmStateChangedEnrichmentTests.cs +git commit -m "feat(commons): enrich AlarmStateChanged with unified condition state (additive)" +``` + +--- + +## Task 3: IAlarmSubscribableConnection seam + DCL alarm messages + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 2, Task 4 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IAlarmSubscribableConnection.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/DataConnection/SubscribeAlarmsRequest.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/DataConnection/NativeAlarmMessages.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs` + +**Step 1: Write the failing test** + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +public class NativeAlarmMessagesTests +{ + [Fact] + public void SubscribeAlarmsRequest_CarriesSourceAndFilter() + { + var r = new SubscribeAlarmsRequest("c1", "inst", "PlantOpcUa", "ns=2;s=Tank01", null, DateTimeOffset.UnixEpoch); + Assert.Equal("ns=2;s=Tank01", r.SourceReference); + Assert.Null(r.ConditionFilter); + } + + [Fact] + public void NativeAlarmTransitionUpdate_WrapsTransition() + { + var t = new NativeAlarmTransition("Tank01.Hi", "Tank01", "x", AlarmTransitionKind.Raise, + new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 500), + "", "", "", "", "", null, DateTimeOffset.UnixEpoch, "", ""); + var u = new NativeAlarmTransitionUpdate("PlantOpcUa", t); + Assert.Equal("PlantOpcUa", u.ConnectionName); + Assert.Equal("Tank01", u.Transition.SourceObjectReference); + } +} +``` + +**Step 2: Run test** — Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter NativeAlarmMessagesTests` → FAIL (types missing). + +**Step 3: Implement** + +```csharp +// IAlarmSubscribableConnection.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; + +/// Callback invoked when a native alarm transition (incl. snapshot replay) arrives. +public delegate void AlarmTransitionCallback(NativeAlarmTransition transition); + +/// Optional capability: an IDataConnection that can mirror a source's native alarms. +public interface IAlarmSubscribableConnection +{ + Task SubscribeAlarmsAsync(string sourceReference, string? conditionFilter, + AlarmTransitionCallback callback, CancellationToken cancellationToken = default); + Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default); +} +``` + +```csharp +// SubscribeAlarmsRequest.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection +public record SubscribeAlarmsRequest( + string CorrelationId, string InstanceUniqueName, string ConnectionName, + string SourceReference, string? ConditionFilter, DateTimeOffset Timestamp); + +public record SubscribeAlarmsResponse( + string CorrelationId, string InstanceUniqueName, bool Success, string? ErrorMessage, DateTimeOffset Timestamp); + +public record UnsubscribeAlarmsRequest( + string CorrelationId, string InstanceUniqueName, string ConnectionName, string SourceReference, DateTimeOffset Timestamp); +``` + +```csharp +// NativeAlarmMessages.cs — namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection +using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; + +/// DCL → instance: a native alarm transition routed by source reference. +public record NativeAlarmTransitionUpdate(string ConnectionName, NativeAlarmTransition Transition); + +/// DCL → instance: the alarm feed for a source is unavailable (connection lost); mark uncertain. +public record NativeAlarmSourceUnavailable(string ConnectionName, string SourceReference, DateTimeOffset Timestamp); +``` + +**Step 4: Run test** → PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IAlarmSubscribableConnection.cs src/ZB.MOM.WW.ScadaBridge.Commons/Messages/DataConnection tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/NativeAlarmMessagesTests.cs +git commit -m "feat(commons): IAlarmSubscribableConnection seam + DCL native alarm messages" +``` + +--- + +## Task 4: Entities + flattened type + Template navigation + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1, Task 2, Task 3 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/TemplateNativeAlarmSource.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/InstanceNativeAlarmSourceOverride.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Templates/Template.cs` (add `NativeAlarmSources` collection) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Entities/Instances/Instance.cs` (add `NativeAlarmSourceOverrides` collection) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs` (add `ResolvedNativeAlarmSource` record + `NativeAlarmSources` list) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NativeAlarmSourceEntityTests.cs` + +**Step 1: Write the failing test** + +```csharp +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; + +public class NativeAlarmSourceEntityTests +{ + [Fact] + public void TemplateNativeAlarmSource_RequiresName() + { + var s = new TemplateNativeAlarmSource("PressureMon") { ConnectionName = "Plant", SourceReference = "ns=2;s=P1" }; + Assert.Equal("PressureMon", s.Name); + Assert.False(s.IsLocked); + } + + [Fact] + public void FlattenedConfiguration_HasNativeAlarmSourcesDefaultEmpty() + { + var f = new FlattenedConfiguration(); + Assert.Empty(f.NativeAlarmSources); + } + + [Fact] + public void ResolvedNativeAlarmSource_DefaultsSourceTemplate() + { + var r = new ResolvedNativeAlarmSource { CanonicalName = "PressureMon", ConnectionName = "Plant", SourceReference = "ns=2;s=P1" }; + Assert.Equal("Template", r.Source); + } +} +``` + +**Step 2: Run test** → FAIL (types/members missing). + +**Step 3: Implement** + +`TemplateNativeAlarmSource.cs` — mirror `TemplateAlarm` (class, ctor takes name): + +```csharp +namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; + +public class TemplateNativeAlarmSource +{ + public int Id { get; set; } + public int TemplateId { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public string ConnectionName { get; set; } = string.Empty; + public string SourceReference { get; set; } = string.Empty; + public string? ConditionFilter { get; set; } // null = mirror all conditions under the source + public bool IsLocked { get; set; } + public bool IsInherited { get; set; } + public bool LockedInDerived { get; set; } + + public TemplateNativeAlarmSource(string name) => + Name = name ?? throw new ArgumentNullException(nameof(name)); +} +``` + +`InstanceNativeAlarmSourceOverride.cs` — mirror `InstanceAlarmOverride`: + +```csharp +namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; + +public class InstanceNativeAlarmSourceOverride +{ + public int Id { get; set; } + public int InstanceId { get; set; } + public string SourceCanonicalName { get; set; } + public string? ConnectionNameOverride { get; set; } // null = keep inherited + public string? SourceReferenceOverride { get; set; } + public string? ConditionFilterOverride { get; set; } + + public InstanceNativeAlarmSourceOverride(string sourceCanonicalName) => + SourceCanonicalName = sourceCanonicalName ?? throw new ArgumentNullException(nameof(sourceCanonicalName)); +} +``` + +In `Template.cs` add (mirror the `Alarms` collection): `public ICollection NativeAlarmSources { get; set; } = new List();` + +In `Instance.cs` add (mirror `AlarmOverrides`): `public ICollection NativeAlarmSourceOverrides { get; set; } = new List();` + +In `FlattenedConfiguration.cs` add to the record: `public IReadOnlyList NativeAlarmSources { get; init; } = [];` and a new record (mirror `ResolvedAlarm`): + +```csharp +/// A fully resolved native alarm source binding (connection + source reference). +public sealed record ResolvedNativeAlarmSource +{ + public string CanonicalName { get; init; } = string.Empty; + public string ConnectionName { get; init; } = string.Empty; + public string SourceReference { get; init; } = string.Empty; + public string? ConditionFilter { get; init; } + public string Source { get; init; } = "Template"; // Template|Inherited|Composed|Override +} +``` + +**Step 4: Run test** → PASS. Also run `dotnet build ZB.MOM.WW.ScadaBridge.slnx` to confirm nothing referencing `Template`/`Instance` broke. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Entities src/ZB.MOM.WW.ScadaBridge.Commons/Types/Flattening/FlattenedConfiguration.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/NativeAlarmSourceEntityTests.cs +git commit -m "feat(commons): native alarm source entities + ResolvedNativeAlarmSource" +``` + +--- + +## Task 5: EF configurations + DbSets + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 8, Task 20 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/TemplateConfiguration.cs` (add `TemplateNativeAlarmSourceConfiguration` class + relationship on `TemplateConfiguration`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InstanceConfiguration.cs` (add `InstanceNativeAlarmSourceOverrideConfiguration` + relationship) +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs` (add two `DbSet`s after line ~65) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NativeAlarmSourceSchemaTests.cs` + +**Step 1: Write the failing test** (in-memory SQLite, `EnsureCreated`, mirrors existing repo-test setup): + +```csharp +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +public class NativeAlarmSourceSchemaTests +{ + [Fact] + public async Task TemplateNativeAlarmSource_PersistsAndEnforcesUniqueName() + { + var opts = new DbContextOptionsBuilder().UseSqlite("DataSource=:memory:").Options; + using var ctx = new ScadaBridgeDbContext(opts); + ctx.Database.OpenConnection(); + ctx.Database.EnsureCreated(); + + var t = new Template("T1"); + t.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src1") { ConnectionName = "C", SourceReference = "r" }); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + + Assert.Single(ctx.TemplateNativeAlarmSources); + } +} +``` + +**Step 2: Run test** → FAIL (`TemplateNativeAlarmSources` DbSet missing). + +**Step 3: Implement** + +In `ScadaBridgeDbContext.cs` after the template/instance DbSets: + +```csharp +public DbSet TemplateNativeAlarmSources => Set(); +public DbSet InstanceNativeAlarmSourceOverrides => Set(); +``` + +(`OnModelCreating` already calls `ApplyConfigurationsFromAssembly` — new `IEntityTypeConfiguration` classes auto-register.) + +In `TemplateConfiguration.cs`, in `TemplateConfiguration.Configure` add the relationship next to `HasMany(t => t.Alarms)`: + +```csharp +builder.HasMany(t => t.NativeAlarmSources).WithOne().HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade); +``` + +Add a sibling config class (mirror `TemplateAlarmConfiguration`): + +```csharp +public class TemplateNativeAlarmSourceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + builder.Property(s => s.Name).IsRequired().HasMaxLength(200); + builder.Property(s => s.Description).HasMaxLength(2000); + builder.Property(s => s.ConnectionName).IsRequired().HasMaxLength(200); + builder.Property(s => s.SourceReference).IsRequired().HasMaxLength(1000); + builder.Property(s => s.ConditionFilter).HasMaxLength(1000); + builder.HasIndex(s => new { s.TemplateId, s.Name }).IsUnique(); + } +} +``` + +In `InstanceConfiguration.cs`, add relationship next to `HasMany(i => i.AlarmOverrides)`: + +```csharp +builder.HasMany(i => i.NativeAlarmSourceOverrides).WithOne().HasForeignKey(o => o.InstanceId).OnDelete(DeleteBehavior.Cascade); +``` + +And a sibling config (mirror `InstanceAlarmOverrideConfiguration`): + +```csharp +public class InstanceNativeAlarmSourceOverrideConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + builder.Property(o => o.SourceCanonicalName).IsRequired().HasMaxLength(400); + builder.Property(o => o.ConnectionNameOverride).HasMaxLength(200); + builder.Property(o => o.SourceReferenceOverride).HasMaxLength(1000); + builder.Property(o => o.ConditionFilterOverride).HasMaxLength(1000); + builder.HasIndex(o => new { o.InstanceId, o.SourceCanonicalName }).IsUnique(); + } +} +``` + +**Step 4: Run test** → PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/NativeAlarmSourceSchemaTests.cs +git commit -m "feat(configdb): EF mappings + DbSets for native alarm source entities" +``` + +--- + +## Task 6: Repository interface + implementation + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 7, Task 8 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs` (add 10 methods) +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs` (implement; mirror alarm-override methods) +- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs` — ensure `GetTemplateWithChildrenAsync` `.Include(t => t.NativeAlarmSources)` and the instance loader `.Include(i => i.NativeAlarmSourceOverrides)` +- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs` (add CRUD facts) + +**Step 1: Write failing tests** — add facts: + +```csharp +[Fact] +public async Task AddAndGetNativeAlarmSourcesByTemplateId_RoundTrips() +{ + var t = new Template("T"); _context.Templates.Add(t); await _context.SaveChangesAsync(); + await _repository.AddTemplateNativeAlarmSourceAsync( + new TemplateNativeAlarmSource("S") { TemplateId = t.Id, ConnectionName = "C", SourceReference = "r" }); + await _context.SaveChangesAsync(); + var list = await _repository.GetNativeAlarmSourcesByTemplateIdAsync(t.Id); + Assert.Single(list); +} + +[Fact] +public async Task GetTemplateWithChildren_IncludesNativeAlarmSources() +{ + var t = new Template("T"); + t.NativeAlarmSources.Add(new TemplateNativeAlarmSource("S") { ConnectionName = "C", SourceReference = "r" }); + _context.Templates.Add(t); await _context.SaveChangesAsync(); + var loaded = await _repository.GetTemplateWithChildrenAsync(t.Id); + Assert.Single(loaded!.NativeAlarmSources); +} +``` + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** — add the 10 method signatures to `ITemplateEngineRepository` and implement them in `TemplateEngineRepository` exactly mirroring the existing alarm-override CRUD (`Get...ByTemplateIdAsync`, `Get...ByIdAsync`, `Add...Async`, `Update...Async`, `Delete...Async` for template; `Get...OverridesByInstanceIdAsync`, `Get...OverrideAsync`, `Add/Update/Delete...OverrideAsync` for instance). Add `.Include(t => t.NativeAlarmSources)` to `GetTemplateWithChildrenAsync` and `.Include(i => i.NativeAlarmSourceOverrides)` to the instance-with-children loader. + +**Step 4: Run test** → PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/TemplateEngineRepositoryTests.cs +git commit -m "feat(configdb): native alarm source repository CRUD + eager-load includes" +``` + +--- + +## Task 7: EF migration + +**Classification:** high-risk +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 6, Task 8 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/_AddNativeAlarmSources.cs` (generated) +- Modify: `.../Migrations/ScadaBridgeDbContextModelSnapshot.cs` (generated) + +**Step 1: Generate the migration** + +Run from repo root: + +```bash +dotnet ef migrations add AddNativeAlarmSources \ + --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \ + --startup-project src/ZB.MOM.WW.ScadaBridge.Host +``` + +**Step 2: Verify** the generated `Up()` creates `Templates.NativeAlarmSources`-equivalent table `NativeAlarmSources` (FK `TemplateId` → `Templates`, cascade; unique index `(TemplateId, Name)`) and `InstanceNativeAlarmSourceOverrides` (FK `InstanceId` → `Instances`, cascade; unique `(InstanceId, SourceCanonicalName)`). Compare shape against `20260513055537_AddInstanceAlarmOverrides.cs`. + +**Step 3: Apply against a throwaway DB to confirm it runs** + +```bash +dotnet build ZB.MOM.WW.ScadaBridge.slnx +# auto-applies in dev on Host startup; or: +dotnet ef database update --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --startup-project src/ZB.MOM.WW.ScadaBridge.Host +``` + +Expected: applies with no error; `dotnet ef migrations list` shows `AddNativeAlarmSources` pending→applied. + +**Step 4: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations +git commit -m "feat(configdb): migration AddNativeAlarmSources" +``` + +--- + +## Task 8: Flattening — ResolveNativeAlarmSources + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 5, Task 6, Task 7 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs` (new `ResolveNativeAlarmSources` + `ResolveComposedNativeAlarmSources` + `ApplyInstanceNativeAlarmSourceOverrides`; call in `Flatten()` Step 5 region; attach to `FlattenedConfiguration`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs` (add facts) + +**Step 1: Write failing tests** (mirror `Flatten_SingleTemplate_ResolvesAttributes`): + +```csharp +[Fact] +public void Flatten_ResolvesNativeAlarmSources_FromTemplate() +{ + var template = CreateTemplate(1, "Base"); + template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure") + { ConnectionName = "Opc", SourceReference = "ns=2;s=P1" }); + var result = _sut.Flatten(CreateInstance(), [template], + new Dictionary>(), + new Dictionary>(), + new Dictionary()); + Assert.True(result.IsSuccess); + Assert.Single(result.Value.NativeAlarmSources); + Assert.Equal("Pressure", result.Value.NativeAlarmSources[0].CanonicalName); + Assert.Equal("ns=2;s=P1", result.Value.NativeAlarmSources[0].SourceReference); +} + +[Fact] +public void Flatten_InstanceOverride_ReplacesSourceReference() +{ + var template = CreateTemplate(1, "Base"); + template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure") + { ConnectionName = "Opc", SourceReference = "ns=2;s=DEFAULT" }); + var instance = CreateInstance(); + instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Pressure") + { SourceReferenceOverride = "ns=2;s=Tank07" }); + var result = _sut.Flatten(instance, [template], new(), new(), new()); + Assert.Equal("ns=2;s=Tank07", result.Value.NativeAlarmSources[0].SourceReference); + Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source); +} +``` + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** — In `Flatten()`'s "Step 5" region (after alarms, ~line 97), add: + +```csharp +var nativeAlarmSources = ResolveInheritedNativeAlarmSources(templateChain, prefix: null); +ResolveComposedNativeAlarmSources(templateChain, compositionMap, composedTemplateChains, nativeAlarmSources); +ApplyInstanceNativeAlarmSourceOverrides(instance.NativeAlarmSourceOverrides, nativeAlarmSources); +``` + +Attach to the returned config: `NativeAlarmSources = nativeAlarmSources.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList()`. + +Implement the three helpers mirroring `ResolveInheritedAlarms` / `ResolveComposedAlarms(Recursive)` / `ApplyInstanceAlarmOverrides`: +- **Inherited:** walk chain base→derived; derived wins unless `IsLocked`; key by bare `Name`; produce `ResolvedNativeAlarmSource { CanonicalName = prefix-qualified Name, ConnectionName, SourceReference, ConditionFilter, Source = inherited?"Inherited":"Template" }`. +- **Composed:** for each composition module, recurse into the composed chain, path-qualify `CanonicalName` to `[ModuleInstanceName].[Name]`, collision-check, `Source = "Composed"`. (No attribute-reference rewriting needed — `SourceReference` is a raw connection address, not an attribute path.) +- **Overrides:** for each `InstanceNativeAlarmSourceOverride` matching `SourceCanonicalName`, apply non-null `ConnectionNameOverride`/`SourceReferenceOverride`/`ConditionFilterOverride`, set `Source = "Override"`. + +**Step 4: Run test** → PASS. Run full TemplateEngine.Tests. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/FlatteningService.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Flattening/FlatteningServiceTests.cs +git commit -m "feat(templateengine): flatten native alarm sources (inherit/compose/override)" +``` + +--- + +## Task 9: Semantic validation + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 10, Task 11, Task 12 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs` (validation block after line ~215; add `ValidationCategory.NativeAlarmSourceInvalid`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/ValidationCategory.cs` (add enum member — confirm location during impl) +- Test: `tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs` + +**Step 1: Write failing tests:** + +```csharp +[Fact] +public void Validate_NativeAlarmSource_UnknownConnection_Errors() +{ + var cfg = ConfigWith(new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "Ghost", SourceReference = "x" }); + var errors = _sut.Validate(cfg, knownConnections: new HashSet { "RealConn" }); + Assert.Contains(errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); +} + +[Fact] +public void Validate_NativeAlarmSource_EmptySourceRef_Errors() +{ + var cfg = ConfigWith(new ResolvedNativeAlarmSource { CanonicalName = "P", ConnectionName = "RealConn", SourceReference = "" }); + var errors = _sut.Validate(cfg, knownConnections: new HashSet { "RealConn" }); + Assert.Contains(errors, e => e.Category == ValidationCategory.NativeAlarmSourceInvalid); +} +``` + +(Confirm `Validate`'s real signature for connection scope during impl; the existing alarm/connection validation already receives connection info — reuse that source rather than adding a new param if available.) + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** — add `NativeAlarmSourceInvalid` to `ValidationCategory`; after the on-trigger-script validation loop, iterate `config.NativeAlarmSources` and `errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid, "...", source.CanonicalName))` when: `ConnectionName` not in the known/alarm-capable connection set, or `SourceReference` is empty. (Protocol alarm-capability check: connection protocol ∈ {OpcUa, MxGateway}.) + +**Step 4: Run test** → PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/SemanticValidator.cs src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/ValidationCategory.cs tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/SemanticValidatorTests.cs +git commit -m "feat(templateengine): validate native alarm source connection + source reference" +``` + +--- + +## Task 10: DataConnectionActor — alarm subscribe/route/unavailable + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 11, Task 12 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs` + +**Design:** add `_alarmSubscribers: Dictionary>` and `_alarmSubByInstance: Dictionary`. Handle `SubscribeAlarmsRequest` in **Connected** (stash in Connecting/Reconnecting); capability-check `_adapter is IAlarmSubscribableConnection` (else reply `SubscribeAlarmsResponse(false, "not alarm-capable")`); call `SubscribeAlarmsAsync` with a callback closure capturing `Self` + `_adapterGeneration` that `Self.Tell(new AlarmTransitionReceived(transition, generation))`. On `AlarmTransitionReceived`, drop if stale generation, else route: `Self.Tell` fan-out to `_alarmSubscribers` whose key prefixes `transition.SourceObjectReference`, wrapping as `NativeAlarmTransitionUpdate(_connectionName, transition)`. On entering **Reconnecting** (existing `PushBadQualityForAllTags`), also send `NativeAlarmSourceUnavailable` to alarm subscribers; on reconnect, re-subscribe alarms (the adapter re-emits a snapshot). Handle `UnsubscribeAlarmsRequest` in all states (mirror `HandleUnsubscribe`). + +**Step 1: Write failing test** (NSubstitute adapter implementing both `IDataConnection` and `IAlarmSubscribableConnection`): + +```csharp +public class DataConnectionActorAlarmTests : TestKit +{ + [Fact] + public void SubscribeAlarms_RoutesTransitionToInstanceSubscriber() + { + AlarmTransitionCallback? cb = null; + var adapter = Substitute.For(); + ((IAlarmSubscribableConnection)adapter) + .SubscribeAlarmsAsync(Arg.Any(), Arg.Any(), Arg.Do(c => cb = c), Arg.Any()) + .Returns(Task.FromResult("alarm-sub-1")); + adapter.ConnectAsync(Arg.Any>(), Arg.Any()).Returns(Task.CompletedTask); + + var actor = Sys.ActorOf(Props.Create(() => new DataConnectionActor( + "conn", adapter, _options, _health, _factory, "OpcUa"))); + + actor.Tell(new SubscribeAlarmsRequest("c","inst","conn","Tank01",null,DateTimeOffset.UtcNow)); + ExpectMsg(m => m.Success); + + var t = new NativeAlarmTransition("Tank01.Hi","Tank01","x",AlarmTransitionKind.Raise, + new AlarmConditionState(true,false,null,AlarmShelveState.Unshelved,false,500),"","","","","",null,DateTimeOffset.UtcNow,"",""); + cb!(t); // adapter fires a transition + ExpectMsg(u => u.Transition.SourceObjectReference == "Tank01"); + } +} +``` + +(Wire `_options`/`_health`/`_factory` per the existing `DataConnectionActorTests` ctor.) + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** the handlers + routing table + stash + unavailable signal as described. Add an internal `record AlarmTransitionReceived(NativeAlarmTransition Transition, int Generation)`. + +**Step 4: Run test** → PASS. Run full DCL actor tests to confirm no regression in tag-subscription paths. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionActorAlarmTests.cs +git commit -m "feat(dcl): DataConnectionActor native alarm subscribe + source-ref routing + unavailable signal" +``` + +--- + +## Task 11: OPC UA A&C adapter + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10, Task 12 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs` (add alarm-subscription method) +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs` (event MonitoredItem + EventFilter + ConditionRefresh + field→transition mapping) +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs` (implement `IAlarmSubscribableConnection`, map client callback → `NativeAlarmTransition`) +- Create: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs` (pure mapping helpers — unit-testable without a server) +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs` + +**Strategy:** Keep server I/O in `RealOpcUaClient`, but isolate the **field → AlarmConditionState/transition** logic in `OpcUaAlarmMapper` so it is unit-testable without a live server (live behavior is covered by the integration task / SkippableFact). `RealOpcUaClient` creates one event `MonitoredItem` (`AttributeId = Attributes.EventNotifier`) on the Server object (`ObjectIds.Server`) with an `EventFilter` selecting EventType, SourceNode, SourceName, Time, Message, Severity, and the `ConditionType`/`AcknowledgeableConditionType`/`AlarmConditionType` state fields; calls the `ConditionRefresh` method on subscribe to replay active conditions; converts each `EventFieldList` to the adapter callback shape. + +**Step 1: Write failing test** (mapper only): + +```csharp +public class OpcUaAlarmMapperTests +{ + [Fact] + public void MapSeverity_ClampsTo0_1000() + { + Assert.Equal(1000, OpcUaAlarmMapper.NormalizeSeverity(5000)); + Assert.Equal(0, OpcUaAlarmMapper.NormalizeSeverity(-1)); + Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500)); + } + + [Fact] + public void BuildCondition_ActiveUnacked() + { + var c = OpcUaAlarmMapper.BuildCondition(active: true, acked: false, confirmed: null, + shelved: false, suppressed: false, severity: 700); + Assert.True(c.Active); + Assert.False(c.Acknowledged); + Assert.Equal(700, c.Severity); + } + + [Fact] + public void DeriveKind_FromStateDelta_AckYieldsAcknowledge() + { + Assert.Equal(AlarmTransitionKind.Acknowledge, + OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true)); + } +} +``` + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** `OpcUaAlarmMapper` (pure static helpers: `NormalizeSeverity`, `BuildCondition`, `DeriveKind`). Then wire `RealOpcUaClient`/`OpcUaDataConnection`: add `IOpcUaClient.CreateAlarmSubscriptionAsync(sourceFilter, conditionFilter, callback)` returning a handle; implement `OpcUaDataConnection.SubscribeAlarmsAsync` to call it and translate to `NativeAlarmTransition` via the mapper; trigger `ConditionRefresh` then emit `Snapshot`/`SnapshotComplete`. + +**Step 4: Run test** → PASS (mapper). Build the project. (Live A&C exercised in Task 28.) + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaAlarmMapper.cs tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmMapperTests.cs +git commit -m "feat(dcl): OPC UA Alarms & Conditions adapter (event subscription + ConditionRefresh)" +``` + +--- + +## Task 12: MxGateway StreamAlarms adapter + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10, Task 11 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs` (add `RunAlarmStreamAsync`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs` (consume package `StreamAlarmsAsync`; resumable; reconnect → snapshot) +- Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs` (implement `IAlarmSubscribableConnection`; background loop; `RaiseDisconnected` on fault) +- Create: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs` (`AlarmFeedMessage`/`OnAlarmTransitionEvent` → `NativeAlarmTransition`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs` +- Verify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj` references a `ZB.MOM.WW.MxGateway.Client` version exposing `StreamAlarmsAsync` (bump if older; OtOpcUa's `GatewayGalaxyAlarmFeed` uses it). + +**Step 1: Write failing test** (mapper, using the generated proto types from `ZB.MOM.WW.MxGateway.Contracts.Proto`): + +```csharp +public class MxGatewayAlarmMapperTests +{ + [Fact] + public void MapTransition_ActiveAcked_FromOnAlarmTransitionEvent() + { + // Build an OnAlarmTransitionEvent (ACK transition) and assert mapping. + var t = MxGatewayAlarmMapper.MapTransition(/* proto OnAlarmTransitionEvent w/ ACKNOWLEDGE */ ...); + Assert.Equal(AlarmTransitionKind.Acknowledge, t.Kind); + Assert.True(t.Condition.Acknowledged); + Assert.Equal("operator1", t.OperatorUser); + } + + [Fact] + public void MapState_ActiveAcked_To_ActiveTrue_AckTrue() + { + var c = MxGatewayAlarmMapper.MapConditionState(/* ALARM_CONDITION_STATE_ACTIVE_ACKED */, severity: 600); + Assert.True(c.Active); Assert.True(c.Acknowledged); Assert.Equal(600, c.Severity); + } +} +``` + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** `MxGatewayAlarmMapper` (proto → `NativeAlarmTransition`; `active_alarm`→`Snapshot`, `snapshot_complete`→`SnapshotComplete`, `transition`→mapped). Add `IMxGatewayClient.RunAlarmStreamAsync(prefix, Action, ct)` and implement in `RealMxGatewayClient` over the package `StreamAlarmsAsync`. Implement `MxGatewayDataConnection.SubscribeAlarmsAsync` (start background loop; `RaiseDisconnected` once-only on fault; the resumed stream re-sends a snapshot). + +**Step 4: Run test** → PASS. Build. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/MxGatewayAlarmMapperTests.cs +git commit -m "feat(dcl): MxGateway StreamAlarms adapter (snapshot + live transitions)" +``` + +--- + +## Task 13: SiteRuntimeOptions — alarm cap + retry + +**Classification:** trivial +**Estimated implement time:** ~2 min +**Parallelizable with:** Task 14 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs` + +**Step 1–3:** Add: + +```csharp +/// Max mirrored native alarms retained per source binding before older entries are dropped (logged). +public int MirroredAlarmCapPerSource { get; set; } = 1000; +/// Interval to retry a failed native alarm subscription. +public int NativeAlarmRetryIntervalMs { get; set; } = 5000; +``` + +**Step 4: Build** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → OK (bound from `ScadaBridge:SiteRuntime`). + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs +git commit -m "feat(siteruntime): native alarm cap + retry options" +``` + +--- + +## Task 14: Site SQLite NativeAlarmState store + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 13 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs` (create table in `InitializeAsync`; add `UpsertNativeAlarmAsync`, `DeleteNativeAlarmAsync`, `GetNativeAlarmsAsync(instance, sourceCanonical)`, `ClearNativeAlarmsForInstanceAsync`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs` + +**Schema** (raw SQL, mirrors `static_attribute_overrides`): `native_alarm_state(instance_unique_name TEXT, source_canonical_name TEXT, source_reference TEXT, condition_json TEXT, last_transition_at TEXT, PRIMARY KEY(instance_unique_name, source_canonical_name, source_reference))`. + +**Step 1: Write failing test:** + +```csharp +public class NativeAlarmStateStoreTests : IDisposable +{ + private readonly string _db = Path.Combine(Path.GetTempPath(), $"nas-{Guid.NewGuid():N}.db"); + private readonly SiteStorageService _s; + public NativeAlarmStateStoreTests() + { _s = new SiteStorageService($"Data Source={_db}", NullLogger.Instance); _s.InitializeAsync().GetAwaiter().GetResult(); } + + [Fact] + public async Task Upsert_Then_Get_RoundTrips() + { + await _s.UpsertNativeAlarmAsync("inst","Src","Tank01.Hi","{\"Active\":true}", DateTimeOffset.UnixEpoch); + var rows = await _s.GetNativeAlarmsAsync("inst","Src"); + Assert.Single(rows); + Assert.Equal("Tank01.Hi", rows[0].SourceReference); + } + public void Dispose() { if (File.Exists(_db)) File.Delete(_db); } +} +``` + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** the table creation in `InitializeAsync` and the four methods following the `SetStaticOverrideAsync` raw-SQL `ON CONFLICT ... DO UPDATE` pattern. Return a small `NativeAlarmRow(SourceReference, ConditionJson, LastTransitionAt)` record. + +**Step 4: Run test** → PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Persistence/SiteStorageService.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Persistence/NativeAlarmStateStoreTests.cs +git commit -m "feat(siteruntime): site SQLite native_alarm_state store" +``` + +--- + +## Task 15: NativeAlarmActor + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs` + +**Behavior:** ctor `(ResolvedNativeAlarmSource source, string instanceName, IActorRef instanceActor, IActorRef dclManager, SiteStorageService storage, SiteRuntimeOptions options, ILogger)`. `PreStart`: rehydrate `_alarms` from `storage.GetNativeAlarmsAsync(instanceName, source.CanonicalName)` (PipeTo), then `dclManager.Tell(new SubscribeAlarmsRequest(...))`. On `NativeAlarmTransitionUpdate`: for `Snapshot` buffer; `SnapshotComplete` atomic-swap (drop missing → emit return-to-normal `AlarmStateChanged`); `Raise/Ack/Clear/Retrigger/StateChange` upsert by `SourceReference` (ignore older `TransitionTime`), persist, emit enriched `AlarmStateChanged` (`Kind = NativeOpcUa/NativeMxAccess`, `Condition`, `SourceReference`, etc.) to `instanceActor`. Retention: drop when `!Active && Acknowledged`. On `NativeAlarmSourceUnavailable`: mark `_alarms` uncertain (re-emit with a quality flag — represent "uncertain" by leaving values but flipping a derived display; minimal: re-emit current condition unchanged but log + set health). Cap `_alarms` at `options.MirroredAlarmCapPerSource` and **log** when dropping (no silent truncation). On `SubscribeAlarmsResponse(false,…)`: schedule a retry timer at `NativeAlarmRetryIntervalMs`. + +**Step 1: Write failing test** (probe as instanceActor, NSubstitute/`TestProbe` as dclManager, temp-file `SiteStorageService`): + +```csharp +[Fact] +public void Raise_EmitsEnrichedAlarmStateChanged() +{ + var instanceProbe = CreateTestProbe(); + var dclProbe = CreateTestProbe(); + var src = new ResolvedNativeAlarmSource { CanonicalName="Pressure", ConnectionName="Opc", SourceReference="ns=2;s=T01" }; + var actor = ActorOf(Props.Create(() => new NativeAlarmActor(src,"inst",instanceProbe.Ref,dclProbe.Ref,_storage,_options,NullLogger.Instance))); + + dclProbe.ExpectMsg(); // subscribed on start + var t = new NativeAlarmTransition("T01.Hi","T01","AnalogLimit.Hi",AlarmTransitionKind.Raise, + new AlarmConditionState(true,false,null,AlarmShelveState.Unshelved,false,800),"Process","hi","hi","","",null,DateTimeOffset.UtcNow,"92","90"); + actor.Tell(new NativeAlarmTransitionUpdate("Opc", t)); + + var emitted = instanceProbe.ExpectMsg(); + Assert.Equal(AlarmKind.NativeOpcUa, emitted.Kind); + Assert.Equal("T01.Hi", emitted.SourceReference); + Assert.Equal(800, emitted.Condition.Severity); +} +``` + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** `NativeAlarmActor` per the behavior above. Derive `Kind` from the connection protocol (pass protocol in via the resolved source's connection, or set `NativeOpcUa` when `SourceReference` looks like a NodeId — simplest: pass a `Kind` hint through the subscribe response; pragmatic first cut: set `Kind = NativeOpcUa` and refine in Task 16 once protocol is known from the connection config). Keep `MapTransitionToStateChanged` a private pure method. + +**Step 4: Run test** → PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs +git commit -m "feat(siteruntime): NativeAlarmActor mirrors source alarms (snapshot swap, retention, persistence)" +``` + +--- + +## Task 16: InstanceActor wiring + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** none + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs` (create `NativeAlarmActor` children from `_configuration.NativeAlarmSources`; pass `_dclManager`; ensure `HandleAlarmStateChanged` stores enriched state + include native alarms in `DebugViewSnapshot`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs` + +**Detail:** After the `foreach (var alarm in _configuration.Alarms)` block (~line 748), add a `foreach (var src in _configuration.NativeAlarmSources)` that does `Context.ActorOf(Props.Create(() => new NativeAlarmActor(src, _instanceUniqueName, Self, _dclManager, _storage, _options, _logger)), $"native-alarm-{src.CanonicalName}")` and tracks it in a `_nativeAlarmActors` dictionary. `HandleAlarmStateChanged` already routes through `_alarmStates`/`_streamManager.PublishAlarmStateChanged`; extend it to also retain the latest enriched `AlarmStateChanged` (store the whole record in a `Dictionary _latestAlarmEvents` keyed by `AlarmName`) so the snapshot can carry enriched fields. In `HandleSubscribeDebugView`, build `alarmStates` from `_latestAlarmEvents.Values` (falling back to the computed projection for alarms with no event yet). Supervision (Resume for children) already covers `NativeAlarmActor`. On redeploy/stop, `ClearNativeAlarmsForInstanceAsync`. + +**Step 1: Write failing test:** + +```csharp +[Fact] +public void DebugViewSnapshot_IncludesNativeAlarm_AfterTransition() +{ + var config = new FlattenedConfiguration { + InstanceUniqueName = "inst", + NativeAlarmSources = [ new ResolvedNativeAlarmSource { CanonicalName="Pressure", ConnectionName="Opc", SourceReference="ns=2;s=T01" } ] + }; + var actor = CreateInstanceActorWithDcl("inst", config, dclProbe.Ref); + // simulate the NativeAlarmActor emitting upward: + actor.Tell(new AlarmStateChanged("inst","T01.Hi",AlarmState.Active,800,DateTimeOffset.UtcNow) + { Kind = AlarmKind.NativeOpcUa, SourceReference="T01.Hi", + Condition = new AlarmConditionState(true,false,null,AlarmShelveState.Unshelved,false,800) }); + actor.Tell(new SubscribeDebugViewRequest("c","inst",DateTimeOffset.UtcNow)); + var snap = ExpectMsg(); + Assert.Contains(snap.AlarmStates, a => a.SourceReference == "T01.Hi" && a.Kind == AlarmKind.NativeOpcUa); +} +``` + +(Extend the test helper to pass a `_dclManager` probe — add an `InstanceActor` ctor overload or thread the probe through `CreateInstanceActor`.) + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** the wiring + `_latestAlarmEvents` retention + enriched snapshot. + +**Step 4: Run test** → PASS. Run full SiteRuntime.Tests. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorNativeAlarmTests.cs +git commit -m "feat(siteruntime): InstanceActor spawns NativeAlarmActors + enriched alarm snapshot" +``` + +--- + +## Task 17: Enrich computed AlarmActor emit + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 18 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs` (set `Condition`/`Kind` on each `AlarmStateChanged` it constructs) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs` (add assertion) + +**Detail:** At each `new AlarmStateChanged(...)` site (binary activate/clear ~lines 207–227; HiLo ~260–266), add the init block: + +```csharp +{ Kind = AlarmKind.Computed, Condition = AlarmConditionStateFactory.ForComputed(state, _priority), Level = ..., Message = ... } +``` + +(The `Condition` getter already defaults to this, so the explicit set is belt-and-suspenders + makes intent clear; minimum change is just confirming `Kind = Computed` which is the default. Add a regression test asserting `Condition.Severity == priority`.) + +**Step 1: Add failing assertion** to an existing activation test: `Assert.Equal(msg.Priority, msg.Condition.Severity);` and `Assert.Equal(AlarmKind.Computed, msg.Kind);` + +**Step 2: Run test** → likely PASS already via defaults; if so this task reduces to adding the regression test. If a code path constructs `AlarmStateChanged` in a way that doesn't reflect `_priority`, fix it. + +**Step 3–4:** Ensure pass. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs +git commit -m "test(siteruntime): assert computed alarms carry unified condition state" +``` + +--- + +## Task 18: Extend sitestream.proto + regenerate + +**Classification:** high-risk +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 17 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto` (append fields 8–21 to `AlarmStateUpdate`) +- Modify (generated): `src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/Sitestream.cs` (+ any `*Grpc.cs`) +- Modify (toggle, then revert): `src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj` + +**Step 1: Edit the proto** — append to `AlarmStateUpdate` (do NOT renumber existing 1–7): + +```protobuf + string kind = 8; + bool active = 9; + bool acknowledged = 10; + bool confirmed = 11; + string shelve_state = 12; + bool suppressed = 13; + string source_reference = 14; + string alarm_type_name = 15; + string category = 16; + string operator_user = 17; + string operator_comment = 18; + google.protobuf.Timestamp original_raise_time = 19; + string current_value = 20; + string limit_value = 21; +``` + +**Step 2: Regenerate (macOS only — the documented manual process):** +1. In the `.csproj`, uncomment the `` block. +2. Temporarily delete/rename the checked-in generated files under `SiteStreamGrpc/`. +3. `dotnet build src/ZB.MOM.WW.ScadaBridge.Communication` (protoc runs on macOS arm64; fails on arm64 Linux — that's why generated files are vendored). +4. Copy generated `Sitestream.cs` / `SitestreamGrpc.cs` from `obj/Debug/net10.0/Protos/` to `SiteStreamGrpc/`. +5. Re-comment the `` block in the `.csproj`. + +**Step 3: Verify** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` compiles and the generated `AlarmStateUpdate` exposes the new properties (e.g. `SourceReference`, `Acknowledged`). + +**Step 4:** No new unit test here (mapping is Task 19). Build is the gate. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj +git commit -m "feat(communication): extend AlarmStateUpdate proto with native alarm fields (regenerated)" +``` + +--- + +## Task 19: gRPC alarm mapping (server + client) + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs` (`HandleAlarmStateChanged` — map new fields out) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs` (`ConvertToDomainEvent` — map new fields back) +- Create: `src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AlarmShelveStateCodec.cs` (string ↔ `AlarmShelveState`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs` (extend round-trip) + +**Step 1: Write failing test** — extend `RelaysAlarmStateChanged_ToProtoEvent`: + +```csharp +var domainEvent = new AlarmStateChanged("Site1.Pump01","T01.Hi",AlarmState.Active,2,timestamp) +{ + Kind = AlarmKind.NativeMxAccess, SourceReference = "T01.Hi", Category = "Process", + OperatorUser = "op1", OperatorComment = "ack", + Condition = new AlarmConditionState(true, true, null, AlarmShelveState.OneShotShelved, false, 900) +}; +actor.Tell(domainEvent); +// ...read protoEvent... +Assert.Equal("T01.Hi", protoEvent.AlarmChanged.SourceReference); +Assert.True(protoEvent.AlarmChanged.Acknowledged); +Assert.Equal(900, protoEvent.AlarmChanged.Suppressed ? 0 : 900); // severity via priority->condition mapping +Assert.Equal("OneShotShelved", protoEvent.AlarmChanged.ShelveState); +Assert.Equal("op1", protoEvent.AlarmChanged.OperatorUser); +``` + +Plus a client-side test: build an `AlarmStateUpdate` with the new fields and assert `ConvertToDomainEvent` populates `Kind`, `Condition`, `SourceReference`, etc. + +**Step 2: Run test** → FAIL. + +**Step 3: Implement** — In `StreamRelayActor.HandleAlarmStateChanged`, set the new proto fields from `msg.Condition`/`msg.Kind`/`msg.SourceReference`/… (severity → `priority` stays; also map `active/acknowledged/confirmed/suppressed`, `shelve_state` via codec, `original_raise_time` via `Timestamp.FromDateTimeOffset` when non-null). In `SiteStreamGrpcClient.ConvertToDomainEvent`, build the `AlarmStateChanged` init block with `Kind = ParseKind(...)`, `Condition = new AlarmConditionState(active, acknowledged, confirmed?, codec.Parse(shelve_state), suppressed, priority)`, and the metadata strings. Add `AlarmShelveStateCodec`. + +**Step 4: Run test** → PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Communication/Actors/StreamRelayActor.cs src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteStreamGrpcClient.cs src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/AlarmShelveStateCodec.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/StreamRelayActorTests.cs +git commit -m "feat(communication): map enriched alarm fields across gRPC (server + client)" +``` + +--- + +## Task 20: Management command contracts + registry + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5, Task 8 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TemplateCommands.cs` (add native-alarm-source commands) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/InstanceCommands.cs` (add override commands) +- Modify: the management command registry (find via `ManagementCommandRegistry` — referenced by `tests/.../ManagementCommandRegistryTests.cs`) to register the new command type names +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs` (assert new commands resolve) + +**Contracts** (mirror `AddTemplateAlarmCommand` / `SetInstanceAlarmOverrideCommand`): + +```csharp +// TemplateCommands.cs +public record AddTemplateNativeAlarmSourceCommand(int TemplateId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked); +public record UpdateTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId, string Name, string ConnectionName, string SourceReference, string? ConditionFilter, string? Description, bool IsLocked); +public record DeleteTemplateNativeAlarmSourceCommand(int NativeAlarmSourceId); +public record ListTemplateNativeAlarmSourcesCommand(int TemplateId); + +// InstanceCommands.cs +public record SetInstanceNativeAlarmSourceOverrideCommand(int InstanceId, string SourceCanonicalName, string? ConnectionNameOverride, string? SourceReferenceOverride, string? ConditionFilterOverride); +public record DeleteInstanceNativeAlarmSourceOverrideCommand(int InstanceId, string SourceCanonicalName); +public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId); +``` + +**Step 1: Write failing test** asserting each new command name resolves through the registry. **Step 2:** FAIL. **Step 3:** add the records + register them. **Step 4:** PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/ManagementCommandRegistryTests.cs +git commit -m "feat(commons): management command contracts for native alarm sources" +``` + +--- + +## Task 21: ManagementActor handlers + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 22, Task 24, Task 25 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/` (the `ManagementActor` and/or its command handler partials — find where `AddTemplateAlarmCommand`/`SetInstanceAlarmOverrideCommand` are handled and add the parallel handlers, calling the Task 6 repository methods within the existing unit-of-work) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs` (CRUD round-trips, incl. authorization: Design role for template, Deployment for instance override — match the existing alarm commands' role checks) + +**Step 1: Write failing tests** mirroring the existing template-alarm + instance-alarm-override handler tests (Add returns `ManagementSuccess`; unauthorized role returns `ManagementUnauthorized`; List returns the rows). **Step 2:** FAIL. **Step 3:** implement handlers + role checks + repository calls + `SaveChangesAsync`. **Step 4:** PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.ManagementService tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +git commit -m "feat(management): handlers for native alarm source CRUD" +``` + +--- + +## Task 22: CLI commands + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 21, Task 24, Task 25 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/TemplateCommands.cs` (add a `native-alarm-source` compound subcommand with `add`/`list`/`remove`, mirroring `BuildAttribute`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/InstanceCommands.cs` (add `native-alarm-source set`/`clear`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (document new commands) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs` (assert tree builds + commands resolve) + +**Step 1: Write failing test** asserting the command tree includes `template native-alarm-source add/list/remove` and `instance native-alarm-source set/clear` and the underlying management commands resolve. **Step 2:** FAIL. **Step 3:** implement via `cmd.SetAction(... CommandHelpers.ExecuteCommandAsync(... AddTemplateNativeAlarmSourceCommand ...))` per the `DataConnectionCommands` pattern. **Step 4:** PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CLI tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CommandTreeTests.cs +git commit -m "feat(cli): native-alarm-source commands (template + instance)" +``` + +--- + +## Task 23: DebugView alarm table enrichment + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 24, Task 25 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor` (add columns/badges; the model already is `AlarmStateChanged`, now enriched) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DebugViewAlarmTableTests.cs` (bUnit) + +**Detail:** Add header cells (Severity, State badge set, Kind, Source, Type, Category) and body cells; render a composite condition badge from `AlarmConditionState` (Active→danger, Acked→muted, Shelved/Suppressed→info), Kind badge (Computed vs Native), and tooltips for operator/comment, raise time, current/limit value. Use the `frontend-design` skill for styling; Bootstrap only. Keep `UpsertWithCap`/filter logic; extend `FilteredAlarmStates` to also match `SourceReference`. + +**Step 1: Write failing bUnit test** — render `DebugView` with a seeded snapshot containing one native `AlarmStateChanged` (Kind=NativeOpcUa, Severity=800, Shelved) and assert the rendered markup shows the severity and a shelved badge. **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DebugViewAlarmTableTests.cs +git commit -m "feat(ui): enrich DebugView alarm table with severity + condition state + native metadata" +``` + +--- + +## Task 24: Template editor — Native Alarm Sources subsection + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 23, Task 25 + +**Files:** +- Modify: the template detail/edit page (`src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/Templates.razor` and/or `TemplateEdit.razor` — locate the Alarms subsection during impl) +- Create (optional): `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/NativeAlarmSourceEditor.razor` +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplateNativeAlarmSourceEditorTests.cs` + +**Detail:** A subsection listing native alarm sources with add/edit/delete; row fields: Name, Connection (dropdown filtered to alarm-capable protocols — reuse the connection-dropdown pattern from `InstanceConfigure`), Source Reference, optional Filter, Lock toggle. Wire add/edit/delete to the Task 20 management commands via the existing management HTTP client. Respect lock/inherit display like the Alarms section. + +**Step 1: Write failing bUnit test** (renders the subsection, simulates add, asserts the management command is posted). **Step 2:** FAIL. **Step 3:** implement (frontend-design skill). **Step 4:** PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplateNativeAlarmSourceEditorTests.cs +git commit -m "feat(ui): template editor Native Alarm Sources subsection" +``` + +--- + +## Task 25: Instance Configure — native alarm source override panel + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 23, Task 24 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor` (new card after Alarm Overrides) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/InstanceConfigureNativeAlarmTests.cs` + +**Detail:** A card listing the instance's resolved native alarm sources with per-row override of Connection/Source Reference/Filter (blank = inherited), saving via `SetInstanceNativeAlarmSourceOverrideCommand`; clear via `DeleteInstanceNativeAlarmSourceOverrideCommand`. Mirror the Connection Bindings + Alarm Overrides UX. + +**Step 1: Write failing bUnit test** (renders card, edits a source-ref override, asserts the set command posts). **Step 2:** FAIL. **Step 3:** implement. **Step 4:** PASS. + +**Step 5: Commit** + +```bash +git add src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/InstanceConfigureNativeAlarmTests.cs +git commit -m "feat(ui): instance configure native alarm source override panel" +``` + +--- + +## Task 26: docker-env2 seed — sample native alarm source + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 27 + +**Files:** +- Modify: the `docker-env2` seed (the script/CLI calls that created the `ScadaBridge Site ` MxGateway connection — find under `docker-env2/`) +- Modify (if needed): `docker-env2/README.md` + +**Detail:** After the existing MxGateway connection seed, add a template `native-alarm-source add` (connection = the seeded MxGateway connection, a representative source reference / MxAccess area) and deploy an instance, so the feature is manually verifiable end-to-end. Use the CLI commands from Task 22. + +**Step 1–3:** Add the seed lines; `bash docker-env2/deploy.sh` (or the seed step) runs without error. **Step 4:** Verify the connection + binding exist via `scadabridge ... template native-alarm-source list`. + +**Step 5: Commit** + +```bash +git add docker-env2 +git commit -m "chore(docker-env2): seed sample native alarm source binding on site-x" +``` + +--- + +## Task 27: Documentation sync + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 26 + +**Files:** +- Modify: `docs/requirements/Component-DataConnectionLayer.md`, `Component-SiteRuntime.md`, `Component-TemplateEngine.md`, `Component-CentralUI.md`, `Component-CLI.md`, `Component-Communication.md` (a.k.a. Central–Site Communication), `Component-ConfigurationDatabase.md` +- Modify: `README.md` (component table notes if needed), `CLAUDE.md` (add a "Native Alarms" bullet under Key Design Decisions → Data & Communication) + +**Detail:** Document the read-only native alarm mirror: the `IAlarmSubscribableConnection` seam + per-connection feed + source-ref routing (DCL doc); `NativeAlarmActor` + `native_alarm_state` SQLite + enriched `AlarmStateChanged` (SiteRuntime doc); `ResolvedNativeAlarmSource` flattening + validation (TemplateEngine doc); enriched `AlarmStateUpdate` proto (Communication doc); new entities/migration/repo (ConfigurationDatabase doc); DebugView enrichment + authoring panels (CentralUI doc); CLI commands (CLI doc). Keep cross-references consistent per CLAUDE.md editing rules. + +**Step 1–3:** Edit docs. **Step 4:** Re-read to confirm no stale cross-references. **Step 5: Commit** + +```bash +git add docs README.md CLAUDE.md +git commit -m "docs: native alarm ingestion across component docs + CLAUDE.md" +``` + +--- + +## Task 28: Integration / live verification + +**Classification:** standard +**Estimated implement time:** ~5 min (plus manual) +**Parallelizable with:** none + +**Files:** +- Create: `tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmLiveSmokeTests.cs` (SkippableFact — skips when no alarm-capable OPC UA server is reachable, mirroring the existing live OPC UA smoke pattern) +- Modify (if needed): `docs/test_infra/test_infra.md` (note A&C requirement) + +**Detail:** Add a SkippableFact that, against an alarm-capable OPC UA endpoint, subscribes via the adapter and asserts a `Snapshot`/`SnapshotComplete` sequence arrives (skip if the infra OPC UA server does not expose Alarms & Conditions). **Document** in `test_infra.md` whether the infra OPC UA server exposes A&C; if not, note the simulation/external-server requirement (open item from the design). Then perform the manual end-to-end check: + +```bash +dotnet build ZB.MOM.WW.ScadaBridge.slnx +bash docker/deploy.sh # or docker-env2/deploy.sh for the MxGateway path +# Open Central UI DebugView for the seeded instance; confirm native alarms appear with severity + condition badges. +``` + +**Step 5: Commit** + +```bash +git add tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaAlarmLiveSmokeTests.cs docs/test_infra/test_infra.md +git commit -m "test(dcl): OPC UA A&C live smoke (skippable) + test-infra note" +``` + +--- + +## Final verification (after all tasks) + +```bash +dotnet build ZB.MOM.WW.ScadaBridge.slnx +dotnet test ZB.MOM.WW.ScadaBridge.slnx +bash docker/deploy.sh # rebuild cluster image for runtime changes (DCL/SiteRuntime/Communication/Host) +``` + +Confirm: native alarms appear in DebugView with severity + condition badges (Active/Acked/Shelved/Suppressed), computed alarms unchanged, no central tables added, and the migration applies cleanly. + +## Open items carried from design +- MxGateway `StreamAlarms` must deliver end-to-end (x86 COM-worker caveat from OtOpcUa) — verified in Task 28 manual check. +- Infra OPC UA server A&C support — resolved/noted in Task 28. +- `ZB.MOM.WW.MxGateway.Client` package must expose `StreamAlarmsAsync` — checked in Task 12. diff --git a/docs/plans/2026-05-29-native-alarms.md.tasks.json b/docs/plans/2026-05-29-native-alarms.md.tasks.json new file mode 100644 index 00000000..f5b8d0cd --- /dev/null +++ b/docs/plans/2026-05-29-native-alarms.md.tasks.json @@ -0,0 +1,34 @@ +{ + "planPath": "docs/plans/2026-05-29-native-alarms.md", + "tasks": [ + {"id": 1, "subject": "Task 1: Commons alarm core types", "status": "pending"}, + {"id": 2, "subject": "Task 2: Extend AlarmStateChanged + computed-default mapping", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: IAlarmSubscribableConnection seam + DCL alarm messages", "status": "pending", "blockedBy": [1]}, + {"id": 4, "subject": "Task 4: Entities + flattened type + Template navigation", "status": "pending"}, + {"id": 5, "subject": "Task 5: EF configurations + DbSets", "status": "pending", "blockedBy": [4]}, + {"id": 6, "subject": "Task 6: Repository interface + implementation", "status": "pending", "blockedBy": [4, 5]}, + {"id": 7, "subject": "Task 7: EF migration AddNativeAlarmSources", "status": "pending", "blockedBy": [5]}, + {"id": 8, "subject": "Task 8: Flattening ResolveNativeAlarmSources", "status": "pending", "blockedBy": [4]}, + {"id": 9, "subject": "Task 9: Semantic validation", "status": "pending", "blockedBy": [4, 8]}, + {"id": 10, "subject": "Task 10: DataConnectionActor alarm subscribe/route/unavailable", "status": "pending", "blockedBy": [3]}, + {"id": 11, "subject": "Task 11: OPC UA A&C adapter", "status": "pending", "blockedBy": [3]}, + {"id": 12, "subject": "Task 12: MxGateway StreamAlarms adapter", "status": "pending", "blockedBy": [3]}, + {"id": 13, "subject": "Task 13: SiteRuntimeOptions alarm cap + retry", "status": "pending"}, + {"id": 14, "subject": "Task 14: Site SQLite NativeAlarmState store", "status": "pending"}, + {"id": 15, "subject": "Task 15: NativeAlarmActor", "status": "pending", "blockedBy": [1, 2, 3, 4, 13, 14]}, + {"id": 16, "subject": "Task 16: InstanceActor wiring", "status": "pending", "blockedBy": [15]}, + {"id": 17, "subject": "Task 17: Enrich computed AlarmActor emit", "status": "pending", "blockedBy": [2]}, + {"id": 18, "subject": "Task 18: Extend sitestream.proto + regenerate", "status": "pending", "blockedBy": [2]}, + {"id": 19, "subject": "Task 19: gRPC alarm mapping (server + client)", "status": "pending", "blockedBy": [2, 18]}, + {"id": 20, "subject": "Task 20: Management command contracts + registry", "status": "pending", "blockedBy": [4]}, + {"id": 21, "subject": "Task 21: ManagementActor handlers", "status": "pending", "blockedBy": [6, 20]}, + {"id": 22, "subject": "Task 22: CLI commands", "status": "pending", "blockedBy": [20]}, + {"id": 23, "subject": "Task 23: DebugView alarm table enrichment", "status": "pending", "blockedBy": [2, 19]}, + {"id": 24, "subject": "Task 24: Template editor Native Alarm Sources subsection", "status": "pending", "blockedBy": [20]}, + {"id": 25, "subject": "Task 25: Instance Configure native alarm source override panel", "status": "pending", "blockedBy": [20]}, + {"id": 26, "subject": "Task 26: docker-env2 seed sample native alarm source", "status": "pending", "blockedBy": [22]}, + {"id": 27, "subject": "Task 27: Documentation sync", "status": "pending", "blockedBy": [16, 19, 22, 23, 24, 25]}, + {"id": 28, "subject": "Task 28: Integration / live verification", "status": "pending", "blockedBy": [10, 11, 12, 16, 19]} + ], + "lastUpdated": "2026-05-29" +}