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

76 KiB
Raw Blame History

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

%%{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

// 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

// 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 }
// 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);
// 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

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

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):

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):

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

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

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

// 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);
}
// 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);
// 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

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

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):

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:

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):

/// <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

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 DbSets 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):

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:

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):

builder.HasMany(t => t.NativeAlarmSources).WithOne().HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade);

Add a sibling config class (mirror TemplateAlarmConfiguration):

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):

builder.HasMany(i => i.NativeAlarmSourceOverrides).WithOne().HasForeignKey(o => o.InstanceId).OnDelete(DeleteBehavior.Cascade);

And a sibling config (mirror InstanceAlarmOverrideConfiguration):

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

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:

[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

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:

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 TemplateIdTemplates, cascade; unique index (TemplateId, Name)) and InstanceNativeAlarmSourceOverrides (FK InstanceIdInstances, cascade; unique (InstanceId, SourceCanonicalName)). Compare shape against 20260513055537_AddInstanceAlarmOverrides.cs.

Step 3: Apply against a throwaway DB to confirm it runs

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

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):

[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:

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

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:

[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

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):

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

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):

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

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/OnAlarmTransitionEventNativeAlarmTransition)
  • 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):

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_alarmSnapshot, snapshot_completeSnapshotComplete, 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

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:

/// <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

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:

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

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):

[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

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:

[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

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:

{ 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

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):

  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

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:

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

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):

// 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

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

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

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

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

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

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

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

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:

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

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)

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.