diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs index 594b2d77..0b8dbff9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxyDriverOptions.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; @@ -26,6 +27,18 @@ public sealed record GalaxyDriverOptions( [Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 30s.", GroupName = "Diagnostics")] [Range(1, 60)] public int ProbeTimeoutSeconds { get; init; } = 30; + + /// + /// v3 authored raw tags this driver serves. The deploy artifact hands each authored raw + /// Tag as a — RawPath identity (the driver wire reference) + /// + driver TagConfig blob (carrying the Galaxy dialled address under the camelCase + /// key attributeRef = tag_name.AttributeName) + the WriteIdempotent flag. The + /// driver builds a RawPath → attributeRef table from these: reads/writes/subscribes + /// arrive keyed by RawPath and the driver dials the mapped attributeRef to MXAccess. Empty + /// when no tags are authored — the driver then treats every reference as its own dialled + /// address (identity), preserving pre-v3 behaviour for the live-browse discovery path. + /// + public IReadOnlyList RawTags { get; init; } = []; } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj index 3c4f01ec..6f3fa0bd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj @@ -5,9 +5,13 @@ enable true - - + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs index 9c9bb6ef..d8630ede 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs @@ -32,24 +32,36 @@ internal static class AlarmRefBuilder /// /// Build an for an alarm-bearing attribute with all - /// five sub-attribute references populated. is the - /// attribute's full reference (e.g. "Tank1.Level.HiHi"); the convention prefixes - /// each suffix to it. + /// five sub-attribute references populated. + /// + /// v3 splits identity from dialling: is the alarm's + /// RawPath — the condition's stable identity, surfaced as + /// and matched against the + /// ConditionId the driver reports on every transition and against the value + /// fan-out key. is the Galaxy attributeRef + /// (tag_name.AttributeName, e.g. "Tank1.Level.HiHi") — the address MXAccess + /// understands; the five live-state / ack sub-refs prefix each suffix onto it so the + /// server's AlarmConditionService subscribes + acks the dialled attributes. In the + /// identity case (no RawPath mapping) both arguments are the same string, reproducing the + /// pre-v3 shape exactly. + /// /// - /// The full reference of the alarm-bearing attribute. + /// The alarm condition's RawPath identity (= ). + /// The Galaxy attributeRef the five sub-refs are dialled from. /// The initial alarm severity level. /// The initial alarm description. /// The populated with all five sub-attribute references. public static AlarmConditionInfo Build( - string fullReference, + string conditionReference, + string dialReference, AlarmSeverity initialSeverity = AlarmSeverity.Medium, string? initialDescription = null) => new( - SourceName: fullReference, + SourceName: conditionReference, InitialSeverity: initialSeverity, InitialDescription: initialDescription, - InAlarmRef: fullReference + InAlarmSuffix, - PriorityRef: fullReference + PrioritySuffix, - DescAttrNameRef: fullReference + DescAttrNameSuffix, - AckedRef: fullReference + AckedSuffix, - AckMsgWriteRef: fullReference + AckMsgSuffix); + InAlarmRef: dialReference + InAlarmSuffix, + PriorityRef: dialReference + PrioritySuffix, + DescAttrNameRef: dialReference + DescAttrNameSuffix, + AckedRef: dialReference + AckedSuffix, + AckMsgWriteRef: dialReference + AckMsgSuffix); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs index 9caeeac2..6242351c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs @@ -31,11 +31,28 @@ public sealed class GalaxyDiscoverer { private readonly IGalaxyHierarchySource _source; + // v3 RawPath keying. Given a discovered Galaxy attributeRef (tag_name.AttributeName), this + // resolves the authored RawTagEntry the deploy artifact delivered for it (keyed by attributeRef), + // or null when the attribute is not authored. When present, the emitted node reference + // (DriverAttributeInfo.FullName) becomes the entry's RawPath — the v3 wire identity the server + // hands back on read/write/subscribe — and WriteIdempotent flows off the entry. When absent (no + // mapping, or the live-browse path with no authored tags) the reference falls back to the + // attributeRef, reproducing the pre-v3 shape exactly. + private readonly Func? _rawTagByAttributeRef; + /// Initializes a new GalaxyDiscoverer with the specified hierarchy source. /// The Galaxy hierarchy source to use for discovery. - public GalaxyDiscoverer(IGalaxyHierarchySource source) + /// + /// Optional v3 resolver mapping a discovered Galaxy attributeRef to its authored + /// (RawPath + WriteIdempotent). Null (or a null return) leaves the + /// emitted reference as the attributeRef — the identity / live-browse fallback. + /// + public GalaxyDiscoverer( + IGalaxyHierarchySource source, + Func? rawTagByAttributeRef = null) { _source = source ?? throw new ArgumentNullException(nameof(source)); + _rawTagByAttributeRef = rawTagByAttributeRef; } /// @@ -79,12 +96,20 @@ public sealed class GalaxyDiscoverer { if (string.IsNullOrEmpty(attr.AttributeName)) continue; - var fullReference = !string.IsNullOrEmpty(attr.FullTagReference) + // attributeRef = the Galaxy dialled address (tag_name.AttributeName) MXAccess reads/writes. + var attributeRef = !string.IsNullOrEmpty(attr.FullTagReference) ? StripArraySuffix(attr.FullTagReference) : obj.TagName + "." + attr.AttributeName; + // v3: the node's driver reference (FullName) is the authored RawPath when this + // attribute is authored; otherwise it degrades to the attributeRef (identity). The + // WriteIdempotent platform flag travels on the RawTagEntry, not inside the blob. + var entry = _rawTagByAttributeRef?.Invoke(attributeRef); + var rawPath = entry?.RawPath ?? attributeRef; + var writeIdempotent = entry?.WriteIdempotent ?? false; + var info = new DriverAttributeInfo( - FullName: fullReference, + FullName: rawPath, DriverDataType: DataTypeMap.Map(attr.MxDataType), IsArray: attr.IsArray, ArrayDim: attr.IsArray && attr.ArrayDimensionPresent && attr.ArrayDimension > 0 @@ -92,15 +117,18 @@ public sealed class GalaxyDiscoverer : null, SecurityClass: SecurityMap.Map(attr.SecurityClassification), IsHistorized: attr.IsHistorized, - IsAlarm: attr.IsAlarm); + IsAlarm: attr.IsAlarm, + WriteIdempotent: writeIdempotent); var handle = folder.Variable(attr.AttributeName, attr.AttributeName, info); // Alarm-bearing attributes ship the full sub-attribute ref set so the server's - // AlarmConditionService can subscribe + ack-write without re-deriving the names. + // AlarmConditionService can subscribe + ack-write without re-deriving the names. The + // condition identity (SourceName / ConditionId) is the RawPath — matching the value + // fan-out key — while the five live-state sub-refs dial off the Galaxy attributeRef. if (attr.IsAlarm) { - handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(fullReference)); + handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(rawPath, attributeRef)); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index aacd8190..011b63ce 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.MxGateway.Client; @@ -64,6 +65,19 @@ public sealed class GalaxyDriver private readonly System.Collections.Concurrent.ConcurrentDictionary _securityByFullRef = new(StringComparer.OrdinalIgnoreCase); + // v3 RawPath keying. The server hands every read/write/subscribe reference as a RawPath; MXAccess + // dials the Galaxy attributeRef (tag_name.AttributeName). These two tables — built once in the ctor + // from options.RawTags (each entry's TagConfig carries the camelCase "attributeRef" key) — bridge the + // two identity domains at the driver boundary: + // _attributeRefByRawPath : RawPath -> attributeRef (forward dial: read/write/subscribe/ack) + // _rawTagByAttributeRef : attributeRef -> RawTagEntry (reverse + WriteIdempotent: discovery, + // OnDataChange, alarm ConditionId) + // Both fall back to identity on a miss, so the live-browse discovery path (no authored tags) and any + // synthetic alarm sub-ref (attributeRef.InAlarm, never an authored RawPath) dial straight through + // unchanged — reproducing pre-v3 behaviour exactly. + private readonly Dictionary _attributeRefByRawPath; + private readonly Dictionary _rawTagByAttributeRef; + // PR 4.4 — subscription lifecycle. The pump consumes the gw event stream and fans // out OnDataChange events to every registered driver subscription via the registry's // reverse map. The subscriber is the test seam — production uses @@ -182,10 +196,85 @@ public sealed class GalaxyDriver _alarmAcknowledger = alarmAcknowledger; _alarmFeed = alarmFeed; + // Build the v3 RawPath ⇄ attributeRef tables from the authored raw tags. Empty when no tags + // are authored (live-browse path), leaving every translation an identity no-op. + (_attributeRefByRawPath, _rawTagByAttributeRef) = BuildRawTagTables(_options.RawTags, _logger); + // Forward the aggregator's transitions through IHostConnectivityProbe. _hostStatuses.OnHostStatusChanged += (_, args) => OnHostStatusChanged?.Invoke(this, args); } + /// + /// Build the RawPath → attributeRef and attributeRef → tables from the + /// authored raw tags. Each entry's carries the Galaxy dialled + /// address under the camelCase key attributeRef. Entries whose blob has no usable + /// attributeRef are skipped (they can't be dialled). On a duplicate attributeRef (two + /// authored RawPaths pointing at the same Galaxy attribute) the first entry wins the reverse map — + /// the value fan-out / alarm ConditionId then surfaces under that first RawPath; the forward map + /// still dials both. Case-insensitive to match the driver's reference handling. + /// + private static (Dictionary Forward, Dictionary Reverse) + BuildRawTagTables(IReadOnlyList rawTags, ILogger logger) + { + var forward = new Dictionary(StringComparer.OrdinalIgnoreCase); + var reverse = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in rawTags) + { + if (string.IsNullOrEmpty(entry.RawPath)) continue; + var attributeRef = TryReadAttributeRef(entry.TagConfig); + if (string.IsNullOrEmpty(attributeRef)) + { + logger.LogWarning( + "GalaxyDriver raw tag {RawPath} has no 'attributeRef' in its TagConfig — skipped (cannot dial MXAccess).", + entry.RawPath); + continue; + } + forward[entry.RawPath] = attributeRef; + reverse.TryAdd(attributeRef, entry); // first-wins on a shared attributeRef + } + return (forward, reverse); + } + + /// + /// Read the Galaxy dialled address from a tag's TagConfig JSON — the camelCase + /// attributeRef key (v3 rename of the pre-v3 PascalCase FullName). Case-insensitive + /// on the property name for authoring tolerance; returns null on an absent/blank/non-string value + /// or unparseable blob (the entry is then skipped, never dialled). + /// + private static string? TryReadAttributeRef(string tagConfigJson) + { + if (string.IsNullOrWhiteSpace(tagConfigJson)) return null; + try + { + using var doc = JsonDocument.Parse(tagConfigJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (!prop.NameEquals("attributeRef") + && !string.Equals(prop.Name, "attributeRef", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (prop.Value.ValueKind != JsonValueKind.String) return null; + var v = prop.Value.GetString(); + return string.IsNullOrWhiteSpace(v) ? null : v; + } + } + catch (JsonException) + { + // Malformed blob — treat as un-dialable; BuildRawTagTables logs the skip. + } + return null; + } + + /// RawPath → Galaxy attributeRef for MXAccess dialling; identity on a miss. + private string ToAttributeRef(string rawPath) => + _attributeRefByRawPath.GetValueOrDefault(rawPath, rawPath); + + /// Galaxy attributeRef → RawPath (the server-facing identity); identity on a miss. + private string ToRawPath(string attributeRef) => + _rawTagByAttributeRef.TryGetValue(attributeRef, out var e) ? e.RawPath : attributeRef; + /// public string DriverInstanceId => _driverInstanceId; @@ -525,11 +614,18 @@ public sealed class GalaxyDriver /// /// Compare two for runtime equivalence — every - /// field that drives gw session shape, address space, or reconnect behaviour - /// must match. Records get value-equality from the language, so a direct - /// equality check is enough. + /// field that drives gw session shape or reconnect behaviour must match. Compared + /// field-wise (the nested records supply value-equality) and excluding + /// : the authored tag set is address-space + /// state re-applied through rediscovery, not a session-teardown trigger, and a record's + /// synthesized equality would compare the two RawTags lists by reference anyway. /// - private static bool OptionsAreEquivalent(GalaxyDriverOptions a, GalaxyDriverOptions b) => a == b; + private static bool OptionsAreEquivalent(GalaxyDriverOptions a, GalaxyDriverOptions b) => + a.Gateway == b.Gateway + && a.MxAccess == b.MxAccess + && a.Repository == b.Repository + && a.Reconnect == b.Reconnect + && a.ProbeTimeoutSeconds == b.ProbeTimeoutSeconds; /// public Task ShutdownAsync(CancellationToken cancellationToken) @@ -596,7 +692,11 @@ public sealed class GalaxyDriver // newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute. var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef); var source = _hierarchySource ??= BuildDefaultHierarchySource(); - var discoverer = new GalaxyDiscoverer(source); + // Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as + // its node reference (FullName) — the security map SecurityCapturingBuilder captures is then + // keyed by RawPath, matching what WriteAsync resolves against. + var discoverer = new GalaxyDiscoverer( + source, attributeRef => _rawTagByAttributeRef.GetValueOrDefault(attributeRef)); await discoverer.DiscoverAsync(capturingBuilder, cancellationToken).ConfigureAwait(false); if (_probeWatcher is not null) @@ -625,10 +725,16 @@ public sealed class GalaxyDriver ArgumentNullException.ThrowIfNull(fullReferences); if (fullReferences.Count == 0) return Task.FromResult>([]); + // v3: the server addresses by RawPath; translate to the Galaxy attributeRef MXAccess dials. + // Read returns positionally, so no reverse translation is needed on the result. An unmapped + // RawPath dials as-is (identity) — the gateway rejects an unknown address → Bad, same as today. + var dialledRefs = new string[fullReferences.Count]; + for (var i = 0; i < fullReferences.Count; i++) dialledRefs[i] = ToAttributeRef(fullReferences[i]); + if (_dataReader is not null) { // Test-only path — tests inject a canned reader via the internal ctor. - return _dataReader.ReadAsync(fullReferences, cancellationToken); + return _dataReader.ReadAsync(dialledRefs, cancellationToken); } if (_subscriber is null) @@ -638,7 +744,7 @@ public sealed class GalaxyDriver "Either inject a test seam via the internal ctor or call InitializeAsync against a real gateway."); } - return ReadViaSubscribeOnceAsync(fullReferences, cancellationToken); + return ReadViaSubscribeOnceAsync(dialledRefs, cancellationToken); } /// @@ -800,7 +906,15 @@ public sealed class GalaxyDriver "issuing writes."); } - return _dataWriter.WriteAsync(writes, ResolveSecurity, cancellationToken); + // v3: WriteRequest.FullReference is a RawPath. Translate to the Galaxy attributeRef the writer + // dials to MXAccess. Security was captured at discovery keyed by RawPath (= the node's FullName), + // so the resolver the writer invokes with the DIALLED attributeRef must reverse it back to RawPath + // before the lookup. Both legs are identity on a miss, so an un-authored tag behaves as before. + var dialledWrites = new List(writes.Count); + foreach (var w in writes) dialledWrites.Add(w with { FullReference = ToAttributeRef(w.FullReference) }); + + return _dataWriter.WriteAsync( + dialledWrites, attributeRef => ResolveSecurity(ToRawPath(attributeRef)), cancellationToken); } // ===== ISubscribable (PR 4.4) ===== @@ -830,6 +944,12 @@ public sealed class GalaxyDriver return new GalaxySubscriptionHandle(subscriptionId); } + // v3: the server subscribes by RawPath; the registry + gateway operate on the Galaxy attributeRef. + // Translate here (identity on a miss — a synthetic alarm sub-ref or live-browse tag dials straight + // through). The value fan-out reverse-translates back to RawPath in OnPumpDataChange. + var dialledRefs = new string[fullReferences.Count]; + for (var i = 0; i < fullReferences.Count; i++) dialledRefs[i] = ToAttributeRef(fullReferences[i]); + // PR 6.3 — when the caller doesn't set a publishing interval (TimeSpan.Zero or // negative), fall back to the configured MxAccess.PublishingIntervalMs. The // server's UA subscription publishingInterval drives this in production; tests @@ -837,7 +957,7 @@ public sealed class GalaxyDriver var requested = (int)Math.Max(0, publishingInterval.TotalMilliseconds); var bufferedIntervalMs = requested > 0 ? requested : _options.MxAccess.PublishingIntervalMs; var results = await _subscriber - .SubscribeBulkAsync(fullReferences, bufferedIntervalMs, cancellationToken) + .SubscribeBulkAsync(dialledRefs, bufferedIntervalMs, cancellationToken) .ConfigureAwait(false); // Build the binding list in input order. Failed entries (gw rejected the tag) are @@ -846,11 +966,13 @@ public sealed class GalaxyDriver // surface lands in PR 5.3's parity tests. // Index results once, correlate in O(1) per reference rather than // FirstOrDefault inside the loop (O(n²) on the 50k-tag path). + // Bindings key on the dialled attributeRef — that's what the gw returns as the tag address and + // what the EventPump fans out on. OnPumpDataChange maps it back to RawPath for public consumers. var resultIndex = BuildResultIndex(results); - var bindings = new List(fullReferences.Count); - for (var i = 0; i < fullReferences.Count; i++) + var bindings = new List(dialledRefs.Length); + for (var i = 0; i < dialledRefs.Length; i++) { - var fullRef = fullReferences[i]; + var fullRef = dialledRefs[i]; var hasMatch = resultIndex.TryGetValue(fullRef, out var match); var itemHandle = hasMatch && match is { WasSuccessful: true } ? match.ItemHandle : 0; bindings.Add(new TagBinding(fullRef, itemHandle)); @@ -1042,12 +1164,14 @@ public sealed class GalaxyDriver // worker's STA queue. foreach (var ack in acknowledgements) { - // ConditionId carries the alarm full reference for the Galaxy driver — - // SourceNodeId is the OPC UA browse path, which the gateway can't address. - // The server-side condition state pairs them through AlarmConditionService. - var alarmFullReference = !string.IsNullOrEmpty(ack.ConditionId) + // ConditionId carries the alarm's RawPath (v3) for the Galaxy driver — SourceNodeId is the + // OPC UA browse path, which the gateway can't address. Translate the RawPath back to the + // Galaxy attributeRef the gateway dials (identity on a miss). The server-side condition state + // pairs them through AlarmConditionService. + var conditionReference = !string.IsNullOrEmpty(ack.ConditionId) ? ack.ConditionId : ack.SourceNodeId; + var alarmFullReference = ToAttributeRef(conditionReference); await _alarmAcknowledger.AcknowledgeAsync( alarmFullReference, ack.Comment ?? string.Empty, @@ -1091,10 +1215,16 @@ public sealed class GalaxyDriver transition.TransitionKind, transition.AlarmFullReference, subCount, handle is not null); if (handle is null) return; + // v3: the ConditionId the driver reports must be the tag's RawPath — the same key the value + // fan-out uses (OnPumpDataChange) and the identity the discoverer stamped as SourceName — so the + // server-side condition state correlates the alarm to its node and a subsequent ack round-trips + // back through AcknowledgeAsync. The feed reports the Galaxy attributeRef; reverse-map it (identity + // on a miss). + var conditionRawPath = ToRawPath(transition.AlarmFullReference); var args = new AlarmEventArgs( SubscriptionHandle: handle, SourceNodeId: transition.SourceObjectReference, - ConditionId: transition.AlarmFullReference, + ConditionId: conditionRawPath, AlarmType: transition.AlarmTypeName, Message: transition.Description, Severity: transition.SeverityBucket, @@ -1132,8 +1262,19 @@ public sealed class GalaxyDriver /// private void OnPumpDataChange(object? sender, DataChangeEventArgs args) { - OnDataChange?.Invoke(this, args); + // v3: the pump fans out keyed by the dialled attributeRef; the server correlates values to nodes + // by RawPath (the node's FullName). Reverse-map before surfacing to public OnDataChange consumers. + // Identity on a miss, so a live-browse tag surfaces under its attributeRef unchanged. + var publicArgs = args; + var rawPath = ToRawPath(args.FullReference); + if (!string.Equals(rawPath, args.FullReference, StringComparison.Ordinal)) + { + publicArgs = args with { FullReference = rawPath }; + } + OnDataChange?.Invoke(this, publicArgs); + // Probe watching stays in the attributeRef domain — ScanState attributes are discovered platform + // attributes, never authored RawPaths, so use the untranslated reference here. if (_probeWatcher is not null && args.FullReference.EndsWith(PerPlatformProbeWatcher.ProbeSuffix, StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs index d4f4f1d1..c8f075a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; @@ -79,7 +80,13 @@ public static class GalaxyDriverFactoryExtensions Reconnect: new GalaxyReconnectOptions( InitialBackoffMs: dto.Reconnect?.InitialBackoffMs ?? 500, MaxBackoffMs: dto.Reconnect?.MaxBackoffMs ?? 30_000, - ReplayOnSessionLost: dto.Reconnect?.ReplayOnSessionLost ?? true)); + ReplayOnSessionLost: dto.Reconnect?.ReplayOnSessionLost ?? true)) + { + // v3: the deploy artifact delivers the authored raw tags (RawPath identity + TagConfig blob + // carrying the camelCase "attributeRef" + WriteIdempotent flag). The driver builds its + // RawPath → attributeRef table from these. Empty when the config omits them. + RawTags = dto.RawTags ?? [], + }; return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger()); } @@ -102,6 +109,12 @@ public static class GalaxyDriverFactoryExtensions public RepositoryDto? Repository { get; init; } /// Gets or sets the reconnect configuration. public ReconnectDto? Reconnect { get; init; } + /// + /// Gets or sets the v3 authored raw tags. Each carries the RawPath + /// identity, the driver TagConfig blob (with the Galaxy attributeRef), and the + /// WriteIdempotent flag. Bound straight through to . + /// + public List? RawTags { get; init; } } /// Gateway configuration section. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs index 62a3af03..258e3ae3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs @@ -289,6 +289,72 @@ public sealed class GalaxyDiscovererTests info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg"); } + /// + /// v3 RawPath keying: when a resolver maps a discovered Galaxy attributeRef to its authored + /// , the emitted node reference () + /// is the entry's RawPath — not the attributeRef — and the entry's WriteIdempotent flag flows + /// onto the descriptor. Unmapped attributes degrade to the attributeRef with WriteIdempotent=false. + /// + [Fact] + public async Task DiscoverAsync_WithRawPathResolver_EmitsRawPathAsFullName_AndThreadsWriteIdempotent() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2), Attr("SP", mxDataType: 2)), + ]); + // Authored: Tank1_Level.PV → RawPath "area/line/tank1/level" (write-idempotent). SP is NOT authored. + var authored = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Tank1_Level.PV"] = new RawTagEntry( + "area/line/tank1/level", "{\"attributeRef\":\"Tank1_Level.PV\"}", WriteIdempotent: true), + }; + var discoverer = new GalaxyDiscoverer(src, attrRef => authored.GetValueOrDefault(attrRef)); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + var pv = builder.Variables.Single(v => v.AttributeName == "PV").Info; + pv.FullName.ShouldBe("area/line/tank1/level"); // RawPath, not the attributeRef + pv.WriteIdempotent.ShouldBeTrue(); + + var sp = builder.Variables.Single(v => v.AttributeName == "SP").Info; + sp.FullName.ShouldBe("Tank1_Level.SP"); // unmapped → attributeRef fallback + sp.WriteIdempotent.ShouldBeFalse(); + } + + /// + /// v3 alarm keying: the alarm condition identity (, and + /// the ConditionId the driver reports) is the tag's RawPath, while the five live-state / + /// ack sub-refs are dialled off the Galaxy attributeRef — so the server subscribes the real + /// MXAccess attributes while correlating the condition to its RawPath-keyed node. + /// + [Fact] + public async Task DiscoverAsync_WithRawPathResolver_AlarmConditionUsesRawPathIdentity_AndAttributeRefSubRefs() + { + var src = new FakeHierarchySource([ + Obj("Tank1_Level", "Level", Attr("HiHi", mxDataType: 0, isAlarm: true)), + ]); + var authored = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Tank1_Level.HiHi"] = new RawTagEntry( + "area/line/tank1/hihi", "{\"attributeRef\":\"Tank1_Level.HiHi\"}", WriteIdempotent: false), + }; + var discoverer = new GalaxyDiscoverer(src, attrRef => authored.GetValueOrDefault(attrRef)); + var builder = new FakeBuilder(); + + await discoverer.DiscoverAsync(builder, CancellationToken.None); + + // Alarm declaration is keyed (by the fake builder) on the variable's FullReference = RawPath. + builder.AlarmDeclarations.ShouldContainKey("area/line/tank1/hihi"); + var info = builder.AlarmDeclarations["area/line/tank1/hihi"]; + info.SourceName.ShouldBe("area/line/tank1/hihi"); // RawPath identity + // Sub-refs dial off the Galaxy attributeRef (tag_name.AttributeName), NOT the RawPath. + info.InAlarmRef.ShouldBe("Tank1_Level.HiHi.InAlarm"); + info.PriorityRef.ShouldBe("Tank1_Level.HiHi.Priority"); + info.DescAttrNameRef.ShouldBe("Tank1_Level.HiHi.DescAttrName"); + info.AckedRef.ShouldBe("Tank1_Level.HiHi.Acked"); + info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg"); + } + /// Verifies that non-alarm attributes are not marked as alarm conditions. [Fact] public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs index e3886373..8e4e5ccc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs @@ -79,6 +79,38 @@ public sealed class GalaxyDriverFactoryTests driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse(); } + /// Verifies that the factory binds the v3 RawTags array from the driver config JSON. + [Fact] + public void CreateInstance_BindsRawTags_FromConfig() + { + const string configWithRawTags = """ + { + "Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" }, + "MxAccess": { "ClientName": "OtOpcUa-A" }, + "RawTags": [ + { "RawPath": "area/line/tank1/level", "TagConfig": "{\"attributeRef\":\"Tank1.Level\"}", "WriteIdempotent": true }, + { "RawPath": "area/line/tank1/sp", "TagConfig": "{\"attributeRef\":\"Tank1.SP\"}", "WriteIdempotent": false } + ] + } + """; + + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-rawtags", configWithRawTags); + + driver.Options.RawTags.Count.ShouldBe(2); + driver.Options.RawTags[0].RawPath.ShouldBe("area/line/tank1/level"); + driver.Options.RawTags[0].TagConfig.ShouldContain("Tank1.Level"); + driver.Options.RawTags[0].WriteIdempotent.ShouldBeTrue(); + driver.Options.RawTags[1].WriteIdempotent.ShouldBeFalse(); + } + + /// Verifies that config without a RawTags array binds to an empty list (identity path). + [Fact] + public void CreateInstance_NoRawTags_DefaultsToEmpty() + { + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-none", MinimalConfig); + driver.Options.RawTags.ShouldBeEmpty(); + } + /// Verifies that missing endpoint throws an exception. [Fact] public void CreateInstance_MissingEndpoint_Throws() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs new file mode 100644 index 00000000..69569dd9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GalaxyDriverRawPathKeyingTests.cs @@ -0,0 +1,260 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; + +/// +/// v3 RawPath identity: the server addresses Galaxy nodes by RawPath, while MXAccess dials the +/// Galaxy attributeRef (tag_name.AttributeName). These tests pin the driver-side boundary +/// translation built from — RawPath → attributeRef on +/// read / write / subscribe / ack (forward dial), and attributeRef → RawPath on the value fan-out +/// and alarm ConditionId (reverse), so the alarm resolves back to the same RawPath the value path +/// surfaces. Each entry's TagConfig carries the dialled address under the camelCase key +/// attributeRef. +/// +public sealed class GalaxyDriverRawPathKeyingTests +{ + private const string RawPath = "area/line/tank1/level"; + private const string AttributeRef = "Tank1.Level"; + + private static GalaxyDriverOptions OptsWithRawTags(bool writeIdempotent = false) => new( + new GalaxyGatewayOptions("https://mxgw.test:5001", "key"), + new GalaxyMxAccessOptions("OtOpcUa-A"), + new GalaxyRepositoryOptions(), + new GalaxyReconnectOptions()) + { + RawTags = [new RawTagEntry(RawPath, $"{{\"attributeRef\":\"{AttributeRef}\"}}", writeIdempotent)], + }; + + // ===== Read: RawPath in → attributeRef dialled ===== + + /// ReadAsync translates the incoming RawPath to the Galaxy attributeRef before dialling. + [Fact] + public async Task ReadAsync_TranslatesRawPath_ToAttributeRef() + { + var reader = new RecordingReader(); + using var driver = new GalaxyDriver("g", OptsWithRawTags(), hierarchySource: null, dataReader: reader); + + await driver.ReadAsync([RawPath], CancellationToken.None); + + reader.LastRequest.ShouldBe([AttributeRef]); + } + + /// An unmapped RawPath dials as-is (identity) — the gateway then rejects an unknown address. + [Fact] + public async Task ReadAsync_UnmappedRawPath_DialsIdentity() + { + var reader = new RecordingReader(); + using var driver = new GalaxyDriver("g", OptsWithRawTags(), hierarchySource: null, dataReader: reader); + + await driver.ReadAsync(["not/authored"], CancellationToken.None); + + reader.LastRequest.ShouldBe(["not/authored"]); + } + + // ===== Write: RawPath in → attributeRef dialled, security resolved by RawPath ===== + + /// + /// WriteAsync dials the attributeRef, and the security the writer resolves is the one captured at + /// discovery under the tag's RawPath (the discoverer emitted FullName = RawPath) — proving the + /// reverse mapping the resolver applies (attributeRef → RawPath) lands on the right classification. + /// + [Fact] + public async Task WriteAsync_DialsAttributeRef_AndResolvesSecurityByRawPath() + { + var writer = new RecordingWriter(); + // Hierarchy: Tank1.Level classified Operate (sec 1). Discovery keys security by the RawPath. + var src = new FakeHierarchySource([Obj("Tank1", Attr("Level", sec: 1))]); + using var driver = new GalaxyDriver( + "g", OptsWithRawTags(), hierarchySource: src, dataReader: null, dataWriter: writer); + + await driver.DiscoverAsync(new CapturingBuilder(), CancellationToken.None); + await driver.WriteAsync([new WriteRequest(RawPath, 42.0)], CancellationToken.None); + + writer.Calls.ShouldHaveSingleItem(); + writer.Calls[0].FullRef.ShouldBe(AttributeRef); // dialled address + writer.Calls[0].Resolved.ShouldBe(SecurityClassification.Operate); // resolved by RawPath + } + + // ===== Subscribe + value fan-out: RawPath in → attributeRef dialled → RawPath out ===== + + /// + /// SubscribeAsync dials the attributeRef; the resulting value fan-out surfaces on the public + /// OnDataChange keyed by the tag's RawPath — the same identity the alarm ConditionId uses. + /// + [Fact] + public async Task Subscribe_DialsAttributeRef_AndValueFanOutSurfacesRawPath() + { + var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber(); + using var driver = new GalaxyDriver( + "g", OptsWithRawTags(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber); + + var captured = new List(); + driver.OnDataChange += (_, args) => captured.Add(args); + + await driver.SubscribeAsync([RawPath], TimeSpan.FromSeconds(1), CancellationToken.None); + + // The gateway saw the dialled attributeRef, not the RawPath. + subscriber.Map.ShouldContainKey(AttributeRef); + subscriber.Map.ShouldNotContainKey(RawPath); + + await subscriber.EmitOnDataChangeAsync(subscriber.Map[AttributeRef], 42.0); + await WaitForAsync(() => captured.Count >= 1); + + captured.ShouldHaveSingleItem(); + captured[0].FullReference.ShouldBe(RawPath); // surfaced under the RawPath identity + ((double)captured[0].Snapshot.Value!).ShouldBe(42.0); + } + + // ===== Alarm: ConditionId is the RawPath; ack round-trips back to the attributeRef ===== + + /// + /// The alarm feed reports the Galaxy attributeRef; the driver surfaces the transition with + /// ConditionId = the tag's RawPath (matching the value path), and a subsequent ack keyed by that + /// RawPath dials back down to the attributeRef the gateway acknowledges. + /// + [Fact] + public async Task Alarm_ConditionIdIsRawPath_AndAckRoundTripsToAttributeRef() + { + var feed = new FakeAlarmFeed(); + var ack = new RecordingAcknowledger(); + using var driver = new GalaxyDriver( + "g", OptsWithRawTags(), hierarchySource: null, alarmAcknowledger: ack, alarmFeed: feed); + + var handle = await driver.SubscribeAlarmsAsync(["Tank1"], CancellationToken.None); + var observed = new List(); + driver.OnAlarmEvent += (_, args) => observed.Add(args); + + // Feed reports the dialled attributeRef. + feed.Emit(NewTransition(AttributeRef, "Tank1")); + + observed.ShouldHaveSingleItem(); + observed[0].ConditionId.ShouldBe(RawPath); // reverse-mapped to the RawPath identity + observed[0].SubscriptionHandle.ShouldBe(handle); + + // Ack keyed by the RawPath the driver reported → dials back to the attributeRef. + await driver.AcknowledgeAsync( + [new AlarmAcknowledgeRequest("Tank1", RawPath, "ack it")], CancellationToken.None); + + ack.Calls.ShouldHaveSingleItem(); + ack.Calls[0].AlarmRef.ShouldBe(AttributeRef); + } + + // ===== helpers / fakes ===== + + private static async Task WaitForAsync(Func condition, int timeoutMs = 2000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(10); + } + } + + private static GalaxyAttribute Attr(string name, int sec) + => new() { AttributeName = name, MxDataType = 2 /*Float32*/, SecurityClassification = sec }; + + private static GalaxyObject Obj(string tag, params GalaxyAttribute[] attrs) + { + var o = new GalaxyObject { TagName = tag, ContainedName = tag }; + o.Attributes.AddRange(attrs); + return o; + } + + private static GalaxyAlarmTransition NewTransition(string alarmFullReference, string source) + => new( + AlarmFullReference: alarmFullReference, + SourceObjectReference: source, + AlarmTypeName: "AnalogLimitAlarm.HiHi", + TransitionKind: GalaxyAlarmTransitionKind.Raise, + SeverityBucket: AlarmSeverity.High, + OpcUaSeverity: 800, + RawMxAccessSeverity: 750, + OriginalRaiseTimestampUtc: null, + TransitionTimestampUtc: DateTime.UtcNow, + OperatorUser: string.Empty, + OperatorComment: string.Empty, + Category: "Process", + Description: "high level"); + + private sealed class FakeHierarchySource(IReadOnlyList objects) : IGalaxyHierarchySource + { + public Task> GetHierarchyAsync(CancellationToken cancellationToken) + => Task.FromResult(objects); + } + + /// Minimal builder — discovery only needs the SecurityCapturingBuilder wrapper to run. + private sealed class CapturingBuilder : IAddressSpaceBuilder + { + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + => new Handle(attributeInfo.FullName); + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + private sealed class Handle(string fullRef) : IVariableHandle + { + public string FullReference { get; } = fullRef; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink(); + private sealed class Sink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } + } + } + + private sealed class RecordingReader : IGalaxyDataReader + { + public IReadOnlyList? LastRequest { get; private set; } + + public Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + LastRequest = fullReferences; + return Task.FromResult>( + fullReferences.Select(_ => new DataValueSnapshot(0.0, StatusCodeMap.Good, null, DateTime.UtcNow)).ToArray()); + } + } + + private sealed class RecordingWriter : IGalaxyDataWriter + { + public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = []; + + public Task> WriteAsync( + IReadOnlyList writes, + Func securityResolver, + CancellationToken cancellationToken) + { + var results = new WriteResult[writes.Count]; + for (var i = 0; i < writes.Count; i++) + { + Calls.Add((writes[i].FullReference, writes[i].Value, securityResolver(writes[i].FullReference))); + results[i] = new WriteResult(StatusCodeMap.Good); + } + return Task.FromResult>(results); + } + + public void InvalidateHandleCaches() { } + } + + private sealed class FakeAlarmFeed : IGalaxyAlarmFeed + { + public event EventHandler? OnAlarmTransition; + public void Start() { } + public void Emit(GalaxyAlarmTransition transition) => OnAlarmTransition?.Invoke(this, transition); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger + { + public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = []; + + public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken) + { + Calls.Add((alarmFullReference, comment, operatorUser)); + return Task.CompletedTask; + } + } +}