v3(galaxy): re-key GalaxyDriver to RawPath identity (dial attributeRef)

Wave-B driver re-keying. Under v3 the server addresses Galaxy nodes by RawPath
and the driver dials the Galaxy attributeRef (tag_name.AttributeName). The
dialled address moved into TagConfig under the camelCase key `attributeRef`
(renamed from the pre-v3 PascalCase FullName) and rides on RawTagEntry.

Options + factory:
- GalaxyDriverOptions gains `IReadOnlyList<RawTagEntry> RawTags` (Contracts now
  references the zero-dep Core.Abstractions leaf for RawTagEntry — same as
  Modbus.Contracts). Factory binds `List<RawTagEntry>? RawTags` from DriverConfig.

Boundary translation (built once from options.RawTags):
- RawPath -> attributeRef (forward dial) on Read / Write / Subscribe / Ack.
- attributeRef -> RawPath (reverse) on the value fan-out (OnDataChange) and the
  alarm ConditionId — so the alarm resolves back to the same RawPath the value
  path surfaces. Both legs fall back to identity on a miss, so the live-browse
  discovery path (no authored tags) and synthetic alarm sub-refs
  (attributeRef.InAlarm) dial straight through unchanged.
- WriteAsync dials the attributeRef but resolves security via the RawPath-keyed
  map the discoverer captured (reverse-maps in the resolver closure).

Discovery + alarms:
- GalaxyDiscoverer emits FullName = RawPath for authored attributes (falls back
  to attributeRef), threads RawTagEntry.WriteIdempotent onto DriverAttributeInfo,
  and gains an optional attributeRef -> RawTagEntry resolver ctor param.
- AlarmRefBuilder.Build(conditionReference, dialReference): SourceName/ConditionId
  = RawPath identity; the five live-state/ack sub-refs dial off the attributeRef.

ReinitializeAsync equivalence now compares the session-shape sections field-wise,
excluding RawTags (address-space state re-applied via rediscovery, and a record's
synthesized equality would compare the lists by reference).

Tests: +9 (discoverer RawPath/WriteIdempotent + alarm identity; factory RawTags
binding; driver read/write/subscribe/alarm boundary translation). Existing 304
preserved via identity fallback. 313 pass, 5 live-gated skips.
This commit is contained in:
Joseph Doherty
2026-07-15 20:12:22 -04:00
parent c379e246d0
commit 636c755b04
9 changed files with 607 additions and 38 deletions
@@ -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<string, SecurityClassification>
_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<string, string> _attributeRefByRawPath;
private readonly Dictionary<string, RawTagEntry> _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);
}
/// <summary>
/// Build the RawPath → attributeRef and attributeRef → <see cref="RawTagEntry"/> tables from the
/// authored raw tags. Each entry's <see cref="RawTagEntry.TagConfig"/> carries the Galaxy dialled
/// address under the camelCase key <c>attributeRef</c>. Entries whose blob has no usable
/// <c>attributeRef</c> 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.
/// </summary>
private static (Dictionary<string, string> Forward, Dictionary<string, RawTagEntry> Reverse)
BuildRawTagTables(IReadOnlyList<RawTagEntry> rawTags, ILogger logger)
{
var forward = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var reverse = new Dictionary<string, RawTagEntry>(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);
}
/// <summary>
/// Read the Galaxy dialled address from a tag's <c>TagConfig</c> JSON — the camelCase
/// <c>attributeRef</c> key (v3 rename of the pre-v3 PascalCase <c>FullName</c>). 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).
/// </summary>
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;
}
/// <summary>RawPath → Galaxy attributeRef for MXAccess dialling; identity on a miss.</summary>
private string ToAttributeRef(string rawPath) =>
_attributeRefByRawPath.GetValueOrDefault(rawPath, rawPath);
/// <summary>Galaxy attributeRef → RawPath (the server-facing identity); identity on a miss.</summary>
private string ToRawPath(string attributeRef) =>
_rawTagByAttributeRef.TryGetValue(attributeRef, out var e) ? e.RawPath : attributeRef;
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
@@ -525,11 +614,18 @@ public sealed class GalaxyDriver
/// <summary>
/// Compare two <see cref="GalaxyDriverOptions"/> 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 <b>excluding
/// <see cref="GalaxyDriverOptions.RawTags"/></b>: 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 <c>RawTags</c> lists by reference anyway.
/// </summary>
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;
/// <inheritdoc />
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<IReadOnlyList<DataValueSnapshot>>([]);
// 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);
}
/// <summary>
@@ -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<WriteRequest>(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<TagBinding>(fullReferences.Count);
for (var i = 0; i < fullReferences.Count; i++)
var bindings = new List<TagBinding>(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
/// </summary>
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))
{