diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs
new file mode 100644
index 0000000..5529097
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs
@@ -0,0 +1,36 @@
+namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
+
+///
+/// One alarm-bearing attribute discovered by
+/// : an attribute whose owning
+/// object configures an AlarmExtension primitive (the same
+/// is_alarm detection used by ).
+/// Used to build the subtag-fallback watch-list.
+///
+public sealed class GalaxyAlarmAttributeRow
+{
+ ///
+ /// Gets the alarm-bearing attribute reference (e.g. Tank01.Level.HiHi),
+ /// matching the full_tag_reference projection of
+ /// .
+ ///
+ public string FullTagReference { get; init; } = string.Empty;
+
+ ///
+ /// Gets the owning object reference (e.g. Tank01). This is the Galaxy
+ /// tag_name — the segment that precedes the first attribute dot in
+ /// .
+ ///
+ public string SourceObjectReference { get; init; } = string.Empty;
+
+ ///
+ /// Gets the writable ack-comment attribute address.
+ ///
+ /// The Galaxy Repository schema does not expose an ack-comment subtag address
+ /// directly, so this is always here. The watch-list
+ /// resolver (a later task) composes the concrete address from configuration plus
+ /// / .
+ ///
+ ///
+ public string AckCommentSubtag { get; init; } = string.Empty;
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs
index db3c171..075dd83 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs
@@ -114,6 +114,56 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
return rows;
}
+ ///
+ /// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list.
+ /// Alarm detection is identical to : a row is
+ /// alarm-bearing when its owning object configures an AlarmExtension
+ /// primitive (the same is_alarm projection, here applied as a SQL filter).
+ ///
+ /// Token to cancel the asynchronous operation.
+ public async Task> GetAlarmAttributesAsync(CancellationToken ct = default)
+ {
+ List rows = new();
+
+ using SqlConnection conn = new(options.ConnectionString);
+ await conn.OpenAsync(ct).ConfigureAwait(false);
+
+ using SqlCommand cmd = new(AlarmAttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
+ using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
+
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ rows.Add(MapAlarmRow(
+ fullTagReference: reader.GetString(0),
+ sourceObjectReference: reader.GetString(1)));
+ }
+ return rows;
+ }
+
+ ///
+ /// Maps a raw alarm-attribute reader row to a .
+ ///
+ /// is the Galaxy tag_name (the
+ /// owning object), and is
+ /// tag_name + '.' + attribute_name — the same composition the
+ /// full_tag_reference projection of produces.
+ /// is left empty here; the
+ /// schema does not expose an ack-comment address and the watch-list resolver
+ /// composes it later.
+ ///
+ /// Exposed internally so the derivation can be unit-tested without a database.
+ ///
+ /// The alarm-bearing attribute reference.
+ /// The owning object reference (tag name).
+ internal static GalaxyAlarmAttributeRow MapAlarmRow(
+ string fullTagReference,
+ string sourceObjectReference) => new()
+ {
+ FullTagReference = fullTagReference,
+ SourceObjectReference = sourceObjectReference,
+ AckCommentSubtag = string.Empty,
+ };
+
private const string HierarchySql = @"
;WITH template_chain AS (
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
@@ -248,5 +298,71 @@ SELECT
FROM ranked r
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
WHERE r.rn = 1
+ORDER BY r.tag_name, r.attribute_name";
+
+ // Alarm-only discovery for the subtag-fallback watch-list. This deliberately reuses the
+ // exact candidate/ranked CTE structure and the same `AlarmExtension`-based is_alarm
+ // detection as AttributesSql so the two queries cannot drift: a row qualifies only when
+ // its user attribute (src_pri 0) anchors an `AlarmExtension` primitive on the owning
+ // object. It projects just what the watch-list needs — full_tag_reference (tag_name +
+ // '.' + attribute_name, matching AttributesSql) and the owning object's tag_name as
+ // source_object_reference. The array `[]` suffix is intentionally omitted: an
+ // alarm-bearing attribute is a scalar anchor, not an array body.
+ private const string AlarmAttributesSql = @"
+;WITH deployed_package_chain AS (
+ SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
+ FROM gobject g
+ INNER JOIN package p ON p.package_id = g.deployed_package_id
+ WHERE g.is_template = 0 AND g.deployed_package_id <> 0
+ UNION ALL
+ SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
+ FROM deployed_package_chain dpc
+ INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
+ WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
+),
+candidate AS (
+ SELECT
+ dpc.gobject_id, g.tag_name, da.attribute_name, dpc.depth, 0 AS src_pri
+ FROM deployed_package_chain dpc
+ INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
+ INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
+ INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
+ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
+ AND da.attribute_name NOT LIKE '[_]%'
+ AND da.attribute_name NOT LIKE '%.Description'
+ AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
+ UNION ALL
+ SELECT
+ dpc.gobject_id, g.tag_name,
+ CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
+ THEN ad.attribute_name
+ ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
+ dpc.depth, 1 AS src_pri
+ FROM deployed_package_chain dpc
+ INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
+ INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id
+ INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
+ INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
+ WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
+ AND ad.attribute_name NOT LIKE '[_]%'
+ AND ad.attribute_name NOT LIKE '%.Description'
+),
+ranked AS (
+ SELECT c.*, ROW_NUMBER() OVER (
+ PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
+ FROM candidate c
+)
+SELECT
+ r.tag_name + '.' + r.attribute_name AS full_tag_reference,
+ r.tag_name AS source_object_reference
+FROM ranked r
+WHERE r.rn = 1
+ AND r.src_pri = 0
+ AND EXISTS (
+ SELECT 1 FROM deployed_package_chain dpc2
+ INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
+ INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
+ WHERE dpc2.gobject_id = r.gobject_id
+ )
ORDER BY r.tag_name, r.attribute_name";
}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs
index 6621837..73fb8d2 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs
@@ -27,4 +27,12 @@ public interface IGalaxyRepository
/// Retrieves all attributes for Galaxy objects from the repository.
/// Token to cancel the asynchronous operation.
Task> GetAttributesAsync(CancellationToken ct = default);
+
+ ///
+ /// Retrieves only the alarm-bearing attributes (those whose owning object
+ /// configures an AlarmExtension primitive) for building the
+ /// subtag-fallback watch-list.
+ ///
+ /// Token to cancel the asynchronous operation.
+ Task> GetAlarmAttributesAsync(CancellationToken ct = default);
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs
new file mode 100644
index 0000000..d3633dc
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs
@@ -0,0 +1,64 @@
+using ZB.MOM.WW.MxGateway.Server.Galaxy;
+
+namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
+
+///
+/// Pure mapper tests for . These assert the
+/// FullTagReference / SourceObjectReference derivation produced by
+/// AlarmAttributesSql without touching a database: the SQL projects
+/// tag_name as the source object and tag_name + '.' + attribute_name as
+/// the full reference, exactly as AttributesSql does.
+///
+public sealed class GalaxyAlarmAttributeMappingTests
+{
+ /// Verifies the mapper copies both projected columns onto the row.
+ [Fact]
+ public void MapAlarmRow_CopiesProjectedColumns()
+ {
+ GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
+ fullTagReference: "Tank01.Level.HiHi",
+ sourceObjectReference: "Tank01");
+
+ Assert.Equal("Tank01.Level.HiHi", row.FullTagReference);
+ Assert.Equal("Tank01", row.SourceObjectReference);
+ }
+
+ ///
+ /// Verifies is always empty:
+ /// the schema does not expose an ack-comment address, so the watch-list resolver
+ /// composes it later from configuration.
+ ///
+ [Fact]
+ public void MapAlarmRow_LeavesAckCommentSubtagEmpty()
+ {
+ GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
+ fullTagReference: "Tank01.Level.HiHi",
+ sourceObjectReference: "Tank01");
+
+ Assert.Equal(string.Empty, row.AckCommentSubtag);
+ }
+
+ ///
+ /// Verifies the SourceObjectReference is the owning object (the SQL tag_name),
+ /// i.e. the segment that precedes the first attribute dot in the full reference, even
+ /// when the attribute itself is a multi-segment extension path.
+ ///
+ [Theory]
+ [InlineData("Tank01", "Level.HiHi", "Tank01.Level.HiHi")]
+ [InlineData("Pump_001", "Speed", "Pump_001.Speed")]
+ [InlineData("TestAlarm001", "Alarm001", "TestAlarm001.Alarm001")]
+ public void MapAlarmRow_SourceObjectIsSegmentBeforeFirstAttributeDot(
+ string tagName,
+ string attributeName,
+ string expectedFullReference)
+ {
+ // Mirror the AlarmAttributesSql projection: full_tag_reference = tag_name + '.' + attribute_name.
+ string fullTagReference = tagName + "." + attributeName;
+
+ GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(fullTagReference, tagName);
+
+ Assert.Equal(expectedFullReference, row.FullTagReference);
+ Assert.Equal(tagName, row.SourceObjectReference);
+ Assert.Equal(row.FullTagReference, row.SourceObjectReference + "." + attributeName);
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs
index 96dcb2a..f7718b3 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs
@@ -378,6 +378,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
///
public Task> GetAttributesAsync(CancellationToken ct = default)
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
+
+ ///
+ public Task> GetAlarmAttributesAsync(CancellationToken ct = default)
+ => throw new InvalidOperationException("GetAlarmAttributesAsync should not be reached");
}
/// Snapshot store whose cancels the token mid-save.
@@ -465,6 +469,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
GetAttributesCount++;
return Task.FromResult(_attributes);
}
+
+ ///
+ public Task> GetAlarmAttributesAsync(CancellationToken ct = default)
+ => Task.FromResult(new List());
}
///
@@ -518,6 +526,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
GetAttributesCount++;
throw toThrow;
}
+
+ ///
+ public Task> GetAlarmAttributesAsync(CancellationToken ct = default)
+ => throw toThrow;
}
}