Files
ScadaBridge/docs/plans/2026-05-29-native-alarms.md
T
Joseph Doherty 43228185b4 docs: convert standard diagrams from draw.io PNGs to inline Mermaid
Gitea renders mermaid inline, so the flow/state/hierarchy/DAG diagrams
move to text-in-markdown: auto-layout (removes the manual overlap-prone
draw.io step), diffable source, no committed binaries, and a dark-text
theme so labels stay legible. Keep draw.io PNGs only for the two complex
bespoke diagrams (logical architecture, env2 topology) where pixel
control still wins. All 24 mermaid blocks validated by rendering.
2026-06-01 00:23:00 -04:00

1590 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<Protobuf>` 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
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart LR
T1["T1"]
T3["T3"]
T2["T2"]
T10["T10<br/>DCL actor"]
T11["T11<br/>OPC UA adapter"]
T12["T12<br/>MxGateway adapter"]
T17["T17<br/>computed AlarmActor enrich"]
T18["T18<br/>proto"]
T19["T19<br/>grpc mapping"]
T23["T23<br/>DebugView"]
T4["T4"]
T5["T5"]
T6["T6"]
T7["T7<br/>migration"]
T8["T8"]
T9["T9<br/>validation"]
T20["T20"]
T21["T21<br/>mgmt handlers"]
T26["T26<br/>seed"]
T22["T22<br/>CLI"]
T24["T24<br/>template UI"]
T25["T25<br/>instance UI"]
T13["T13"]
T14["T14"]
T15["T15<br/>NativeAlarmActor"]
T16["T16<br/>InstanceActor wiring"]
T15IN["inputs to T15:<br/>T1, T2, T3, T4 (Resolved), T13, T14"]
T27["T27<br/>docs"]
T28["T28<br/>integration / manual verify"]
EVT["(everything) emits to T27 and T28"]
T1 --> T2
T1 --> T10
T1 --> T11
T1 --> T12
T3 --> T2
T3 --> T10
T3 --> T11
T3 --> T12
T2 --> T17
T2 --> T18
T18 --> T19
T19 --> T23
T4 --> T5
T4 --> T7
T4 --> T8
T4 --> T20
T5 --> T6
T6 --> T21
T8 --> T9
T20 --> T21
T20 --> T22
T20 --> T24
T20 --> T25
T21 --> T26
T13 --> T15
T14 --> T15
T15 --> T16
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class T1,T2,T3,T10,T11,T12 proc
class T17,T18,T19,T23 alt
class T4,T5,T6,T7,T8,T9,T20,T21,T22,T24,T25,T26 start
class T13,T14 dec
class T15,T16 warn
class T27,T28,T15IN,EVT muted
```
---
## 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;
/// <summary>Orthogonal OPC UA Part 9 sub-conditions + severity. Read-only mirror of the source.</summary>
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;
/// <summary>Protocol-neutral alarm transition emitted by an IAlarmSubscribableConnection adapter.</summary>
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
{
/// <summary>Computed alarms are auto-acked, never shelved/suppressed; severity = priority.</summary>
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;
/// <summary>Unified A&C-style condition. Defaults to a computed mapping of State+Priority.</summary>
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;
/// <summary>Callback invoked when a native alarm transition (incl. snapshot replay) arrives.</summary>
public delegate void AlarmTransitionCallback(NativeAlarmTransition transition);
/// <summary>Optional capability: an IDataConnection that can mirror a source's native alarms.</summary>
public interface IAlarmSubscribableConnection
{
Task<string> 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;
/// <summary>DCL → instance: a native alarm transition routed by source reference.</summary>
public record NativeAlarmTransitionUpdate(string ConnectionName, NativeAlarmTransition Transition);
/// <summary>DCL → instance: the alarm feed for a source is unavailable (connection lost); mark uncertain.</summary>
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<TemplateNativeAlarmSource> NativeAlarmSources { get; set; } = new List<TemplateNativeAlarmSource>();`
In `Instance.cs` add (mirror `AlarmOverrides`): `public ICollection<InstanceNativeAlarmSourceOverride> NativeAlarmSourceOverrides { get; set; } = new List<InstanceNativeAlarmSourceOverride>();`
In `FlattenedConfiguration.cs` add to the record: `public IReadOnlyList<ResolvedNativeAlarmSource> NativeAlarmSources { get; init; } = [];` and a new record (mirror `ResolvedAlarm`):
```csharp
/// <summary>A fully resolved native alarm source binding (connection + source reference).</summary>
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<ScadaBridgeDbContext>().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<TemplateNativeAlarmSource> TemplateNativeAlarmSources => Set<TemplateNativeAlarmSource>();
public DbSet<InstanceNativeAlarmSourceOverride> InstanceNativeAlarmSourceOverrides => Set<InstanceNativeAlarmSourceOverride>();
```
(`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<TemplateNativeAlarmSource>
{
public void Configure(EntityTypeBuilder<TemplateNativeAlarmSource> 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<InstanceNativeAlarmSourceOverride>
{
public void Configure(EntityTypeBuilder<InstanceNativeAlarmSourceOverride> 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/<timestamp>_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<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
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<string> { "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<string> { "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<string /*sourceObjectRef*/, HashSet<IActorRef>>` and `_alarmSubByInstance: Dictionary<string, (IActorRef, string sourceRef)>`. 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<IDataConnection, IAlarmSubscribableConnection>();
((IAlarmSubscribableConnection)adapter)
.SubscribeAlarmsAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Do<AlarmTransitionCallback>(c => cb = c), Arg.Any<CancellationToken>())
.Returns(Task.FromResult("alarm-sub-1"));
adapter.ConnectAsync(Arg.Any<IDictionary<string,string>>(), Arg.Any<CancellationToken>()).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<SubscribeAlarmsResponse>(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<NativeAlarmTransitionUpdate>(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<NativeAlarmTransition>, 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 13:** Add:
```csharp
/// <summary>Max mirrored native alarms retained per source binding before older entries are dropped (logged).</summary>
public int MirroredAlarmCapPerSource { get; set; } = 1000;
/// <summary>Interval to retry a failed native alarm subscription.</summary>
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<SiteStorageService>.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<NativeAlarmActor>.Instance)));
dclProbe.ExpectMsg<SubscribeAlarmsRequest>(); // 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<AlarmStateChanged>();
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<string, AlarmStateChanged> _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<DebugViewSnapshot>();
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 207227; HiLo ~260266), 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 34:** 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 821 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 17):
```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 `<Protobuf Include="Protos\sitestream.proto" GrpcServices="Both" />` 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 `<Protobuf>` 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 <X>` 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 13:** 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. CentralSite 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 13:** 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.