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;
+ }
+ }
+}