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:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// v3 authored raw tags this driver serves. The deploy artifact hands each authored raw
|
||||
/// <c>Tag</c> as a <see cref="RawTagEntry"/> — RawPath identity (the driver wire reference)
|
||||
/// + driver <c>TagConfig</c> blob (carrying the Galaxy dialled address under the camelCase
|
||||
/// key <c>attributeRef</c> = <c>tag_name.AttributeName</c>) + the WriteIdempotent flag. The
|
||||
/// driver builds a <c>RawPath → attributeRef</c> 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+6
-2
@@ -5,9 +5,13 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO ProjectReference. The only PackageReference is the logging abstraction -->
|
||||
<!-- needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||
<ItemGroup>
|
||||
<!-- v3 RawPath identity: GalaxyDriverOptions.RawTags is IReadOnlyList<RawTagEntry>,
|
||||
delivered by the deploy artifact. RawTagEntry lives in the zero-dependency
|
||||
Core.Abstractions leaf beside EquipmentTagRefResolver — same reference Modbus.Contracts
|
||||
takes for its RawTags. Core.Abstractions has no deps, so no cycle. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
|
||||
<!-- Logging abstraction needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -32,24 +32,36 @@ internal static class AlarmRefBuilder
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AlarmConditionInfo"/> for an alarm-bearing attribute with all
|
||||
/// five sub-attribute references populated. <paramref name="fullReference"/> is the
|
||||
/// attribute's full reference (e.g. <c>"Tank1.Level.HiHi"</c>); the convention prefixes
|
||||
/// each suffix to it.
|
||||
/// five sub-attribute references populated.
|
||||
/// <para>
|
||||
/// v3 splits identity from dialling: <paramref name="conditionReference"/> is the alarm's
|
||||
/// <b>RawPath</b> — the condition's stable identity, surfaced as
|
||||
/// <see cref="AlarmConditionInfo.SourceName"/> and matched against the
|
||||
/// <c>ConditionId</c> the driver reports on every transition and against the value
|
||||
/// fan-out key. <paramref name="dialReference"/> is the Galaxy <b>attributeRef</b>
|
||||
/// (<c>tag_name.AttributeName</c>, e.g. <c>"Tank1.Level.HiHi"</c>) — the address MXAccess
|
||||
/// understands; the five live-state / ack sub-refs prefix each suffix onto it so the
|
||||
/// server's <c>AlarmConditionService</c> subscribes + acks the dialled attributes. In the
|
||||
/// identity case (no RawPath mapping) both arguments are the same string, reproducing the
|
||||
/// pre-v3 shape exactly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference of the alarm-bearing attribute.</param>
|
||||
/// <param name="conditionReference">The alarm condition's RawPath identity (= <see cref="AlarmConditionInfo.SourceName"/>).</param>
|
||||
/// <param name="dialReference">The Galaxy attributeRef the five sub-refs are dialled from.</param>
|
||||
/// <param name="initialSeverity">The initial alarm severity level.</param>
|
||||
/// <param name="initialDescription">The initial alarm description.</param>
|
||||
/// <returns>The populated <see cref="AlarmConditionInfo"/> with all five sub-attribute references.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, RawTagEntry?>? _rawTagByAttributeRef;
|
||||
|
||||
/// <summary>Initializes a new GalaxyDiscoverer with the specified hierarchy source.</summary>
|
||||
/// <param name="source">The Galaxy hierarchy source to use for discovery.</param>
|
||||
public GalaxyDiscoverer(IGalaxyHierarchySource source)
|
||||
/// <param name="rawTagByAttributeRef">
|
||||
/// Optional v3 resolver mapping a discovered Galaxy attributeRef to its authored
|
||||
/// <see cref="RawTagEntry"/> (RawPath + WriteIdempotent). Null (or a null return) leaves the
|
||||
/// emitted reference as the attributeRef — the identity / live-browse fallback.
|
||||
/// </param>
|
||||
public GalaxyDiscoverer(
|
||||
IGalaxyHierarchySource source,
|
||||
Func<string, RawTagEntry?>? rawTagByAttributeRef = null)
|
||||
{
|
||||
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
_rawTagByAttributeRef = rawTagByAttributeRef;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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<GalaxyDriver>());
|
||||
}
|
||||
@@ -102,6 +109,12 @@ public static class GalaxyDriverFactoryExtensions
|
||||
public RepositoryDto? Repository { get; init; }
|
||||
/// <summary>Gets or sets the reconnect configuration.</summary>
|
||||
public ReconnectDto? Reconnect { get; init; }
|
||||
/// <summary>
|
||||
/// Gets or sets the v3 authored raw tags. Each <see cref="RawTagEntry"/> carries the RawPath
|
||||
/// identity, the driver <c>TagConfig</c> blob (with the Galaxy <c>attributeRef</c>), and the
|
||||
/// WriteIdempotent flag. Bound straight through to <see cref="GalaxyDriverOptions.RawTags"/>.
|
||||
/// </summary>
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Gateway configuration section.</summary>
|
||||
|
||||
@@ -289,6 +289,72 @@ public sealed class GalaxyDiscovererTests
|
||||
info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 RawPath keying: when a resolver maps a discovered Galaxy attributeRef to its authored
|
||||
/// <see cref="RawTagEntry"/>, the emitted node reference (<see cref="DriverAttributeInfo.FullName"/>)
|
||||
/// is the entry's <b>RawPath</b> — not the attributeRef — and the entry's WriteIdempotent flag flows
|
||||
/// onto the descriptor. Unmapped attributes degrade to the attributeRef with WriteIdempotent=false.
|
||||
/// </summary>
|
||||
[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<string, RawTagEntry>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 alarm keying: the alarm condition identity (<see cref="AlarmConditionInfo.SourceName"/>, and
|
||||
/// the ConditionId the driver reports) is the tag's <b>RawPath</b>, while the five live-state /
|
||||
/// ack sub-refs are dialled off the Galaxy <b>attributeRef</b> — so the server subscribes the real
|
||||
/// MXAccess attributes while correlating the condition to its RawPath-keyed node.
|
||||
/// </summary>
|
||||
[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<string, RawTagEntry>(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");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-alarm attributes are not marked as alarm conditions.</summary>
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition()
|
||||
|
||||
@@ -79,6 +79,38 @@ public sealed class GalaxyDriverFactoryTests
|
||||
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the factory binds the v3 RawTags array from the driver config JSON.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that config without a RawTags array binds to an empty list (identity path).</summary>
|
||||
[Fact]
|
||||
public void CreateInstance_NoRawTags_DefaultsToEmpty()
|
||||
{
|
||||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-none", MinimalConfig);
|
||||
driver.Options.RawTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that missing endpoint throws an exception.</summary>
|
||||
[Fact]
|
||||
public void CreateInstance_MissingEndpoint_Throws()
|
||||
|
||||
+260
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// v3 RawPath identity: the server addresses Galaxy nodes by RawPath, while MXAccess dials the
|
||||
/// Galaxy attributeRef (<c>tag_name.AttributeName</c>). These tests pin the driver-side boundary
|
||||
/// translation built from <see cref="GalaxyDriverOptions.RawTags"/> — 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 <c>TagConfig</c> carries the dialled address under the camelCase key
|
||||
/// <c>attributeRef</c>.
|
||||
/// </summary>
|
||||
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 =====
|
||||
|
||||
/// <summary>ReadAsync translates the incoming RawPath to the Galaxy attributeRef before dialling.</summary>
|
||||
[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]);
|
||||
}
|
||||
|
||||
/// <summary>An unmapped RawPath dials as-is (identity) — the gateway then rejects an unknown address.</summary>
|
||||
[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 =====
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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 =====
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<DataChangeEventArgs>();
|
||||
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 =====
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AlarmEventArgs>();
|
||||
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<bool> 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<GalaxyObject> objects) : IGalaxyHierarchySource
|
||||
{
|
||||
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(objects);
|
||||
}
|
||||
|
||||
/// <summary>Minimal builder — discovery only needs the SecurityCapturingBuilder wrapper to run.</summary>
|
||||
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<string>? LastRequest { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = fullReferences;
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(
|
||||
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<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
Func<string, SecurityClassification> 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<IReadOnlyList<WriteResult>>(results);
|
||||
}
|
||||
|
||||
public void InvalidateHandleCaches() { }
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
|
||||
{
|
||||
public event EventHandler<GalaxyAlarmTransition>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user