diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index b8581c8..241b89b 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -818,6 +818,16 @@ subtags (confirmed AVEVA `AlarmExtension` field names, verified against the live ZB Galaxy `attribute_definition` rows). The gateway re-runs discovery on its reconcile cadence and pushes an updated watch-list when the model changes. +Each target's canonical `AlarmFullReference` is composed as +`Galaxy!{area}.{reference}` (literal `Galaxy` provider). The `{area}` is the +alarm object's **real Galaxy area** — discovered per object via +`gobject.area_gobject_id` (`GetAlarmAttributesAsync` projects it as `area_name`) +— so the synthesized reference's group matches exactly the area the native +alarmmgr (wnwrap) emits for the same alarm (e.g. `TestMachine_001` in `TestArea` +yields `Galaxy!TestArea.TestMachine_001.TestAlarm001`). The configured +`Discovery.Area` / `DefaultArea` is **only** the fallback for explicit +`IncludeAttributes` entries, which carry no discovered area. + ### Subtag advise and `LmxSubtagAlarmSource` `LmxSubtagAlarmSource` (implements `ISubtagAlarmSource`) owns a separate diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index 4778e58..7d2523b 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -242,7 +242,7 @@ native wnwrap alarm-manager provider and the subtag-monitoring fallback. | `MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds` | `30` | While in subtag mode, how often (in seconds) the monitor probes the wnwrap provider to detect recovery. Floored at 1. | | `MxGateway:Alarms:Fallback:FailbackStableProbes` | `3` | Number of consecutive clean wnwrap probes required before the monitor switches back to the alarm manager. Floored at 1. | | `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. | -| `MxGateway:Alarms:Fallback:Discovery:Area` | _(empty)_ | Galaxy area to scope the Repository query to. Falls back to `MxGateway:Alarms:DefaultArea` when empty. Ignored when `UseGalaxyRepository` is `false`. | +| `MxGateway:Alarms:Fallback:Discovery:Area` | _(empty)_ | Galaxy area to scope the Repository query to. Falls back to `MxGateway:Alarms:DefaultArea` when empty. Ignored when `UseGalaxyRepository` is `false`. This area is **not** used to compose a Repository-derived alarm's canonical `Galaxy!{area}.{reference}`: each discovered alarm uses its object's real Galaxy area (discovered via `gobject.area_gobject_id`), so the reference's group matches what the native alarmmgr emits. `Discovery:Area` / `DefaultArea` is used as the composition area only for explicit `IncludeAttributes` entries, which carry no discovered area. | | `MxGateway:Alarms:Fallback:Discovery:IncludeAttributes` | _(empty)_ | Explicit MXAccess attribute paths to add to the subtag watch-list, supplementing (or replacing, when `UseGalaxyRepository` is `false`) the Repository-derived list. | | `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the Repository-derived watch-list. Ignored when `UseGalaxyRepository` is `false`. | | `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. | diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs index c7e4923..879621b 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs @@ -13,7 +13,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Alarms; // 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. +// names are not hard-coded here. The {area} is the alarm object's REAL Galaxy +// area discovered via gobject.area_gobject_id (the alarm group the native +// alarmmgr emits), giving exact reference parity with wnwrap. The configured +// Discovery.Area/DefaultArea is only the fallback for explicit IncludeAttributes +// entries, which carry no discovered area. public sealed class AlarmWatchListResolver : IAlarmWatchListResolver { private const string ProviderLiteral = "Galaxy"; @@ -43,9 +47,16 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver AlarmDiscoveryOptions discovery = options.Fallback.Discovery; + // Config fallback area used only for explicit IncludeAttributes entries (which + // carry no discovered area): discovery area, else the default area (may be empty). + string configFallbackArea = string.IsNullOrEmpty(discovery.Area) ? options.DefaultArea : discovery.Area; + // 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 = []; + // Each entry carries the reference, the source-object reference, and the + // per-entry area used to compose the canonical reference. GR rows contribute + // the object's real Galaxy area; config includes contribute the config + // fallback area (Discovery.Area else DefaultArea). + List<(string Reference, string SourceObject, string Area)> ordered = []; HashSet seen = new(StringComparer.OrdinalIgnoreCase); if (discovery.UseGalaxyRepository) @@ -73,7 +84,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver continue; } - ordered.Add((row.FullTagReference, row.SourceObjectReference)); + ordered.Add((row.FullTagReference, row.SourceObjectReference, row.Area)); } } @@ -84,7 +95,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver continue; } - ordered.Add((include, DeriveSourceObject(include))); + ordered.Add((include, DeriveSourceObject(include), configFallbackArea)); } // Remove excluded references (case-insensitive), but only when GR discovery @@ -112,12 +123,11 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver 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. + // 3. Compose one target per reference, using the PER-ENTRY area: the GR row's + // real Galaxy area (matching the alarmmgr group), or the config fallback for + // explicit includes. List targets = new(ordered.Count); - foreach ((string reference, string sourceObject) in ordered) + foreach ((string reference, string sourceObject, string area) in ordered) { targets.Add(new AlarmSubtagTarget { @@ -130,7 +140,7 @@ public sealed class AlarmWatchListResolver : IAlarmWatchListResolver }); } - // 5. Report the resolved count; warn when subtag mode was expected to cover + // 4. Report the resolved count; warn when subtag mode was expected to cover // something (GR enabled, or explicit includes were configured) but resolved // to nothing. Only emit the Debug line when there is at least one target, // to avoid a confusing "0 target(s)" noise line. diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs index 5529097..fa175cf 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs @@ -23,6 +23,18 @@ public sealed class GalaxyAlarmAttributeRow /// public string SourceObjectReference { get; init; } = string.Empty; + /// + /// Gets the owning object's Galaxy area (e.g. TestArea) — the alarm group. + /// + /// Resolved via gobject.area_gobject_id in AlarmAttributesSql. The + /// watch-list resolver composes the canonical Galaxy!{area}.{reference} from + /// this so the synthesized reference's group matches the native alarmmgr (wnwrap) + /// for reference parity. May be when the object has no + /// area; the resolver then falls back to the configured area. + /// + /// + public string Area { get; init; } = string.Empty; + /// /// Gets the writable ack-comment attribute address. /// diff --git a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs index 075dd83..a934466 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs @@ -135,7 +135,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR { rows.Add(MapAlarmRow( fullTagReference: reader.GetString(0), - sourceObjectReference: reader.GetString(1))); + sourceObjectReference: reader.GetString(1), + area: reader.GetString(2))); } return rows; } @@ -151,16 +152,23 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR /// schema does not expose an ack-comment address and the watch-list resolver /// composes it later. /// + /// is the owning object's real Galaxy area (its alarm + /// group), resolved via gobject.area_gobject_id; the watch-list resolver + /// composes the canonical reference from it so the synthesized reference's group + /// matches what the native alarmmgr (wnwrap) emits. /// Exposed internally so the derivation can be unit-tested without a database. /// /// The alarm-bearing attribute reference. /// The owning object reference (tag name). + /// The owning object's Galaxy area (the alarm group). internal static GalaxyAlarmAttributeRow MapAlarmRow( string fullTagReference, - string sourceObjectReference) => new() + string sourceObjectReference, + string area) => new() { FullTagReference = fullTagReference, SourceObjectReference = sourceObjectReference, + Area = area, AckCommentSubtag = string.Empty, }; @@ -307,7 +315,9 @@ ORDER BY r.tag_name, r.attribute_name"; // 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. + // alarm-bearing attribute is a scalar anchor, not an array body. It also projects the + // owning object's real Galaxy area (via gobject.area_gobject_id) as area_name so the + // watch-list resolver composes a reference whose group matches the native alarmmgr. private const string AlarmAttributesSql = @" ;WITH deployed_package_chain AS ( SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth @@ -354,8 +364,11 @@ ranked AS ( ) SELECT r.tag_name + '.' + r.attribute_name AS full_tag_reference, - r.tag_name AS source_object_reference + r.tag_name AS source_object_reference, + ISNULL(area.tag_name, '') AS area_name FROM ranked r +INNER JOIN gobject g ON g.gobject_id = r.gobject_id +LEFT JOIN gobject area ON area.gobject_id = g.area_gobject_id WHERE r.rn = 1 AND r.src_pri = 0 AND EXISTS ( diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs index e9e1239..86e9de3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs @@ -44,10 +44,10 @@ public sealed class AlarmWatchListResolverTests { StubGalaxyRepository repo = new( [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, - new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02", Area = "TestArea" }, // Duplicate of an include below (case-insensitive) — should appear once. - new GalaxyAlarmAttributeRow { FullTagReference = "Pump01.Fault", SourceObjectReference = "Pump01" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Pump01.Fault", SourceObjectReference = "Pump01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); @@ -68,7 +68,7 @@ public sealed class AlarmWatchListResolverTests { StubGalaxyRepository repo = new( [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); @@ -94,7 +94,7 @@ public sealed class AlarmWatchListResolverTests { StubGalaxyRepository repo = new( [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); @@ -111,27 +111,37 @@ public sealed class AlarmWatchListResolverTests } [Fact] - public async Task ResolveAsync_ComposesCanonicalFullReference_WithArea() + public async Task ResolveAsync_ComposesCanonicalFullReference_FromRealGalaxyArea_NotConfigArea() { + // The GR row carries the object's real Galaxy area (the alarmmgr group). The + // composed reference must use that area, NOT the configured Discovery.Area. StubGalaxyRepository repo = new( [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + new GalaxyAlarmAttributeRow + { + FullTagReference = "TestMachine_001.TestAlarm001", + SourceObjectReference = "TestMachine_001", + Area = "TestArea", + }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); - IReadOnlyList result = await resolver.ResolveAsync(Options(area: "Site_A")); + // Config area "DEV" must be ignored for a GR row that has a discovered area. + IReadOnlyList result = await resolver.ResolveAsync( + Options(area: "DEV", defaultArea: "DEV")); AlarmSubtagTarget target = Assert.Single(result); - Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference); + Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", target.AlarmFullReference); } [Fact] public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea() { + // GR row with no discovered area and no config area -> bare Galaxy!{reference}. StubGalaxyRepository repo = new( [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); @@ -144,16 +154,34 @@ public sealed class AlarmWatchListResolverTests } [Fact] - public async Task ResolveAsync_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty() + public async Task ResolveAsync_ConfigInclude_UsesDiscoveryAreaFallback() { - StubGalaxyRepository repo = new( - [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, - ]); + // A config IncludeAttributes entry has no discovered area, so it uses the + // config fallback: Discovery.Area when set. + StubGalaxyRepository repo = new([]); AlarmWatchListResolver resolver = CreateResolver(repo); - IReadOnlyList result = await resolver.ResolveAsync(Options(area: "", defaultArea: "Plant")); + IReadOnlyList result = await resolver.ResolveAsync(Options( + area: "Site_A", + include: ["Tank01.Level.HiHi"])); + + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference); + } + + [Fact] + public async Task ResolveAsync_ConfigInclude_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty() + { + // A config IncludeAttributes entry with no Discovery.Area uses DefaultArea. + StubGalaxyRepository repo = new([]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + area: "", + defaultArea: "Plant", + include: ["Tank01.Level.HiHi"])); AlarmSubtagTarget target = Assert.Single(result); Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference); @@ -239,8 +267,8 @@ public sealed class AlarmWatchListResolverTests { StubGalaxyRepository repo = new( [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, - new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); @@ -263,7 +291,7 @@ public sealed class AlarmWatchListResolverTests { StubGalaxyRepository repo = new( [ - new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01", Area = "TestArea" }, ]); AlarmWatchListResolver resolver = CreateResolver(repo); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs index d3633dc..b02ae02 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Galaxy/GalaxyAlarmAttributeMappingTests.cs @@ -11,16 +11,18 @@ namespace ZB.MOM.WW.MxGateway.Tests.Galaxy; /// public sealed class GalaxyAlarmAttributeMappingTests { - /// Verifies the mapper copies both projected columns onto the row. + /// Verifies the mapper copies all projected columns onto the row. [Fact] public void MapAlarmRow_CopiesProjectedColumns() { GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow( fullTagReference: "Tank01.Level.HiHi", - sourceObjectReference: "Tank01"); + sourceObjectReference: "Tank01", + area: "TestArea"); Assert.Equal("Tank01.Level.HiHi", row.FullTagReference); Assert.Equal("Tank01", row.SourceObjectReference); + Assert.Equal("TestArea", row.Area); } /// @@ -33,7 +35,8 @@ public sealed class GalaxyAlarmAttributeMappingTests { GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow( fullTagReference: "Tank01.Level.HiHi", - sourceObjectReference: "Tank01"); + sourceObjectReference: "Tank01", + area: "TestArea"); Assert.Equal(string.Empty, row.AckCommentSubtag); } @@ -55,10 +58,11 @@ public sealed class GalaxyAlarmAttributeMappingTests // Mirror the AlarmAttributesSql projection: full_tag_reference = tag_name + '.' + attribute_name. string fullTagReference = tagName + "." + attributeName; - GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(fullTagReference, tagName); + GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(fullTagReference, tagName, area: "TestArea"); Assert.Equal(expectedFullReference, row.FullTagReference); Assert.Equal(tagName, row.SourceObjectReference); + Assert.Equal("TestArea", row.Area); Assert.Equal(row.FullTagReference, row.SourceObjectReference + "." + attributeName); } } diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs index 7567f7d..15b8493 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs @@ -37,6 +37,28 @@ public sealed class SubtagAlarmStateMachineTests Assert.Equal("Area", e.Record.Group); } + [Fact] + public void ActiveFalseToTrue_AlarmMgrShape_EmitsNativeProviderGroupTagName() + { + // Reference parity: a subtag target composed from the object's real Galaxy + // area must round-trip to exactly the native alarmmgr (wnwrap) record fields: + // Provider "Galaxy", Group = the real area "TestArea", and the object-rooted + // TagName "TestMachine_001.TestAlarm001". + var target = new AlarmSubtagTarget + { + AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + SourceObjectReference = "TestMachine_001", + ActiveSubtag = "TestMachine_001.TestAlarm001.InAlarm", + }; + var sm = new SubtagAlarmStateMachine(new[] { target }); + var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc); + var events = sm.Apply("TestMachine_001.TestAlarm001.InAlarm", true, ts); + var e = Assert.Single(events); + Assert.Equal("Galaxy", e.Record.ProviderName); + Assert.Equal("TestArea", e.Record.Group); + Assert.Equal("TestMachine_001.TestAlarm001", e.Record.TagName); + } + [Fact] public void ActiveFalseToTrue_NoProviderBang_UsesWholeReferenceAsTagName() {