diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs
new file mode 100644
index 0000000..fd89a26
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs
@@ -0,0 +1,158 @@
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.MxGateway.Server.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Server.Alarms;
+
+///
+/// Default . Merges Galaxy Repository
+/// alarm-attribute discovery with the configured include/exclude overrides
+/// and composes the per-attribute subtag item addresses from the configured
+/// subtag names.
+///
+// NOTE: The exact subtag names and the canonical AlarmFullReference shape
+// ("Galaxy!{area}.{reference}") are validated against a live Galaxy in the
+// Task 17 live smoke test. The config Subtags block exists precisely so these
+// names are not hard-coded here.
+public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
+{
+ private const string ProviderLiteral = "Galaxy";
+ private const string DefaultActiveSubtag = "active";
+ private const string DefaultAckedSubtag = "acked";
+
+ private readonly IGalaxyRepository _repository;
+ private readonly ILogger _logger;
+
+ /// Initializes the watch-list resolver.
+ /// Galaxy Repository used for alarm-attribute discovery.
+ /// Diagnostic logger.
+ public AlarmWatchListResolver(
+ IGalaxyRepository repository,
+ ILogger logger)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ public async Task> ResolveAsync(
+ AlarmsOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
+
+ // 1. Build the ordered, de-duplicated attribute reference set.
+ // Each entry carries the reference plus the source-object reference.
+ List<(string Reference, string SourceObject)> ordered = [];
+ HashSet seen = new(StringComparer.OrdinalIgnoreCase);
+
+ if (discovery.UseGalaxyRepository)
+ {
+ List rows;
+ try
+ {
+ rows = await _repository.GetAlarmAttributesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Discovery being unavailable must not crash the resolver: log and
+ // continue with an empty discovery set. The caller decides what to
+ // do with the (possibly config-only) result.
+ _logger.LogWarning(
+ ex,
+ "Galaxy Repository alarm-attribute discovery failed; continuing with configuration-only watch-list.");
+ rows = [];
+ }
+
+ foreach (GalaxyAlarmAttributeRow row in rows)
+ {
+ if (string.IsNullOrEmpty(row.FullTagReference) || !seen.Add(row.FullTagReference))
+ {
+ continue;
+ }
+
+ ordered.Add((row.FullTagReference, row.SourceObjectReference));
+ }
+ }
+
+ foreach (string include in discovery.IncludeAttributes)
+ {
+ if (string.IsNullOrEmpty(include) || !seen.Add(include))
+ {
+ continue;
+ }
+
+ ordered.Add((include, DeriveSourceObject(include)));
+ }
+
+ // Remove excluded references (case-insensitive). Excludes are rare, so a
+ // per-entry lookup against a small set is fine.
+ HashSet excluded = new(discovery.ExcludeAttributes, StringComparer.OrdinalIgnoreCase);
+ if (excluded.Count > 0)
+ {
+ ordered.RemoveAll(e => excluded.Contains(e.Reference));
+ }
+
+ // 2. Resolve subtag names with safe fallbacks.
+ string active = string.IsNullOrEmpty(options.Fallback.Subtags.Active)
+ ? DefaultActiveSubtag
+ : options.Fallback.Subtags.Active;
+ string acked = string.IsNullOrEmpty(options.Fallback.Subtags.Acked)
+ ? DefaultAckedSubtag
+ : options.Fallback.Subtags.Acked;
+ string priority = options.Fallback.Subtags.Priority;
+ string ackComment = options.Fallback.Subtags.AckComment;
+
+ // 3. Resolve the area: discovery area, else the default area (may be empty).
+ string area = string.IsNullOrEmpty(discovery.Area) ? options.DefaultArea : discovery.Area;
+
+ // 4. Compose one target per reference.
+ List targets = new(ordered.Count);
+ foreach ((string reference, string sourceObject) in ordered)
+ {
+ targets.Add(new AlarmSubtagTarget
+ {
+ AlarmFullReference = ComposeFullReference(area, reference),
+ SourceObjectReference = sourceObject,
+ ActiveSubtag = $"{reference}.{active}",
+ AckedSubtag = $"{reference}.{acked}",
+ PrioritySubtag = string.IsNullOrEmpty(priority) ? string.Empty : $"{reference}.{priority}",
+ AckCommentSubtag = string.IsNullOrEmpty(ackComment) ? string.Empty : $"{reference}.{ackComment}",
+ });
+ }
+
+ // 5. Report the resolved count; warn if subtag mode would cover nothing.
+ if (targets.Count == 0 && discovery.UseGalaxyRepository)
+ {
+ _logger.LogWarning(
+ "Alarm subtag watch-list resolved to zero targets; subtag-polling fallback will cover no alarms.");
+ }
+ else
+ {
+ _logger.LogDebug("Resolved alarm subtag watch-list with {TargetCount} target(s).", targets.Count);
+ }
+
+ return targets;
+ }
+
+ ///
+ /// Derives the source-object reference for a configuration entry: the
+ /// substring before the first '.', or the whole string when there is no dot.
+ ///
+ private static string DeriveSourceObject(string reference)
+ {
+ int dot = reference.IndexOf('.', StringComparison.Ordinal);
+ return dot < 0 ? reference : reference[..dot];
+ }
+
+ ///
+ /// Composes the canonical alarm full reference: Galaxy!{area}.{reference}
+ /// when an area is set, otherwise Galaxy!{reference}.
+ ///
+ private static string ComposeFullReference(string area, string reference) =>
+ string.IsNullOrEmpty(area)
+ ? $"{ProviderLiteral}!{reference}"
+ : $"{ProviderLiteral}!{area}.{reference}";
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs
new file mode 100644
index 0000000..7581d5c
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs
@@ -0,0 +1,28 @@
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
+
+namespace ZB.MOM.WW.MxGateway.Server.Alarms;
+
+///
+/// Resolves the subtag watch-list the gateway sends to the worker when the
+/// central alarm monitor operates in subtag-polling fallback mode. Merges
+/// Galaxy Repository alarm-attribute discovery with the configured
+/// include/exclude overrides and composes the per-attribute subtag item
+/// addresses from the configured subtag names.
+///
+public interface IAlarmWatchListResolver
+{
+ ///
+ /// Builds the subtag watch-list for the supplied alarm configuration.
+ ///
+ /// Alarm configuration carrying discovery and subtag-name settings.
+ /// Token to cancel the asynchronous operation.
+ ///
+ /// The resolved watch-list, possibly empty.
+ /// Discovery being unavailable never throws; the caller decides what to do
+ /// with an empty list.
+ ///
+ Task> ResolveAsync(
+ AlarmsOptions options,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs
new file mode 100644
index 0000000..c6b5262
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs
@@ -0,0 +1,262 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Server.Alarms;
+using ZB.MOM.WW.MxGateway.Server.Configuration;
+using ZB.MOM.WW.MxGateway.Server.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
+
+///
+/// Unit tests for : discovery/config merge,
+/// subtag-address composition, canonical reference shaping, and the
+/// unavailable-discovery code path.
+///
+public sealed class AlarmWatchListResolverTests
+{
+ private static AlarmWatchListResolver CreateResolver(IGalaxyRepository repository) =>
+ new(repository, NullLogger.Instance);
+
+ private static AlarmsOptions Options(
+ bool useGalaxyRepository = true,
+ string area = "",
+ string defaultArea = "",
+ string[]? include = null,
+ string[]? exclude = null,
+ AlarmSubtagNameOptions? subtags = null) =>
+ new()
+ {
+ DefaultArea = defaultArea,
+ Fallback = new AlarmFallbackOptions
+ {
+ Discovery = new AlarmDiscoveryOptions
+ {
+ UseGalaxyRepository = useGalaxyRepository,
+ Area = area,
+ IncludeAttributes = include ?? [],
+ ExcludeAttributes = exclude ?? [],
+ },
+ Subtags = subtags ?? new AlarmSubtagNameOptions(),
+ },
+ };
+
+ [Fact]
+ public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates()
+ {
+ StubGalaxyRepository repo = new(
+ [
+ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" },
+ new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02" },
+ // Duplicate of an include below (case-insensitive) — should appear once.
+ new GalaxyAlarmAttributeRow { FullTagReference = "Pump01.Fault", SourceObjectReference = "Pump01" },
+ ]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ IReadOnlyList result = await resolver.ResolveAsync(Options(
+ include: ["pump01.fault", "Valve03.Position.Lo"],
+ exclude: ["Tank02.Level.HiHi"]));
+
+ Assert.Equal(
+ new[] { "Tank01.Level.HiHi", "Pump01.Fault", "Valve03.Position.Lo" },
+ result.Select(t => t.ActiveSubtag.Replace(".active", string.Empty, StringComparison.Ordinal)));
+ // De-dup preserved first (GR) occurrence; exclude removed Tank02.
+ Assert.Equal(3, result.Count);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames()
+ {
+ StubGalaxyRepository repo = new(
+ [
+ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" },
+ ]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ IReadOnlyList result = await resolver.ResolveAsync(Options(
+ subtags: new AlarmSubtagNameOptions
+ {
+ Active = "InAlarm",
+ Acked = "Ack",
+ Priority = "Pri",
+ AckComment = "AckCmt",
+ }));
+
+ AlarmSubtagTarget target = Assert.Single(result);
+ Assert.Equal("Tank01.Level.HiHi.InAlarm", target.ActiveSubtag);
+ Assert.Equal("Tank01.Level.HiHi.Ack", target.AckedSubtag);
+ Assert.Equal("Tank01.Level.HiHi.Pri", target.PrioritySubtag);
+ Assert.Equal("Tank01.Level.HiHi.AckCmt", target.AckCommentSubtag);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty()
+ {
+ StubGalaxyRepository repo = new(
+ [
+ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" },
+ ]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ // Default Priority is "priority"; force it empty alongside empty AckComment.
+ IReadOnlyList result = await resolver.ResolveAsync(Options(
+ subtags: new AlarmSubtagNameOptions { Priority = string.Empty, AckComment = string.Empty }));
+
+ AlarmSubtagTarget target = Assert.Single(result);
+ Assert.Equal("Tank01.Level.HiHi.active", target.ActiveSubtag);
+ Assert.Equal("Tank01.Level.HiHi.acked", target.AckedSubtag);
+ Assert.Equal(string.Empty, target.PrioritySubtag);
+ Assert.Equal(string.Empty, target.AckCommentSubtag);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_ComposesCanonicalFullReference_WithArea()
+ {
+ StubGalaxyRepository repo = new(
+ [
+ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" },
+ ]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ IReadOnlyList result = await resolver.ResolveAsync(Options(area: "Site_A"));
+
+ AlarmSubtagTarget target = Assert.Single(result);
+ Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea()
+ {
+ StubGalaxyRepository repo = new(
+ [
+ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" },
+ ]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ // No discovery area and no default area.
+ IReadOnlyList result = await resolver.ResolveAsync(Options());
+
+ AlarmSubtagTarget target = Assert.Single(result);
+ Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty()
+ {
+ StubGalaxyRepository repo = new(
+ [
+ new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" },
+ ]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ IReadOnlyList result = await resolver.ResolveAsync(Options(area: "", defaultArea: "Plant"));
+
+ AlarmSubtagTarget target = Assert.Single(result);
+ Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly()
+ {
+ StubGalaxyRepository repo = new(
+ [
+ new GalaxyAlarmAttributeRow { FullTagReference = "ShouldNotAppear.X", SourceObjectReference = "ShouldNotAppear" },
+ ]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ IReadOnlyList result = await resolver.ResolveAsync(Options(
+ useGalaxyRepository: false,
+ include: ["Tank01.Level.HiHi"]));
+
+ Assert.Equal(0, repo.GetAlarmAttributesCount);
+ AlarmSubtagTarget target = Assert.Single(result);
+ Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet()
+ {
+ ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable"));
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ IReadOnlyList result = await resolver.ResolveAsync(Options(
+ include: ["Tank01.Level.HiHi"]));
+
+ // Did not throw; discovery set empty, include retained.
+ AlarmSubtagTarget target = Assert.Single(result);
+ Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference);
+ }
+
+ [Fact]
+ public async Task ResolveAsync_DerivesSourceObjectForConfigEntry()
+ {
+ StubGalaxyRepository repo = new([]);
+
+ AlarmWatchListResolver resolver = CreateResolver(repo);
+
+ IReadOnlyList result = await resolver.ResolveAsync(Options(
+ include: ["Tank01.Level.HiHi", "StandaloneTag"]));
+
+ Assert.Equal("Tank01", result[0].SourceObjectReference);
+ // No dot — whole string is the source object.
+ Assert.Equal("StandaloneTag", result[1].SourceObjectReference);
+ }
+
+ /// In-memory returning a fixed alarm rowset.
+ private sealed class StubGalaxyRepository(List rows) : IGalaxyRepository
+ {
+ /// Gets the number of times was called.
+ public int GetAlarmAttributesCount { get; private set; }
+
+ ///
+ public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
+
+ ///
+ public Task GetLastDeployTimeAsync(CancellationToken ct = default) =>
+ Task.FromResult(null);
+
+ ///
+ public Task> GetHierarchyAsync(CancellationToken ct = default) =>
+ Task.FromResult(new List());
+
+ ///
+ public Task> GetAttributesAsync(CancellationToken ct = default) =>
+ Task.FromResult(new List());
+
+ ///
+ public Task> GetAlarmAttributesAsync(CancellationToken ct = default)
+ {
+ GetAlarmAttributesCount++;
+ return Task.FromResult(rows);
+ }
+ }
+
+ /// whose alarm-attribute query throws.
+ private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
+ {
+ ///
+ public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
+
+ ///
+ public Task GetLastDeployTimeAsync(CancellationToken ct = default) =>
+ Task.FromResult(null);
+
+ ///
+ public Task> GetHierarchyAsync(CancellationToken ct = default) =>
+ Task.FromResult(new List());
+
+ ///
+ public Task> GetAttributesAsync(CancellationToken ct = default) =>
+ Task.FromResult(new List());
+
+ ///
+ public Task> GetAlarmAttributesAsync(CancellationToken ct = default) =>
+ Task.FromException>(toThrow);
+ }
+}