diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs
new file mode 100644
index 0000000..bd0d4b6
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/AlarmRefBuilder.cs
@@ -0,0 +1,51 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
+
+///
+/// Populates the five sub-attribute references on
+/// by Galaxy convention. The server-level AlarmConditionService (PR 2.2) uses
+/// these to subscribe to live alarm-state attributes and to route ack writes back to
+/// the alarm tag.
+///
+///
+/// Galaxy alarms expose four runtime attributes plus a write-only ack target,
+/// consistently named on every alarm-bearing object:
+///
+/// - <tag>.<attr>.InAlarm
+/// - <tag>.<attr>.Priority
+/// - <tag>.<attr>.DescAttrName
+/// - <tag>.<attr>.Acked
+/// - <tag>.<attr>.AckMsg
+///
+/// This is the same convention the legacy GalaxyAlarmTracker hard-coded; we
+/// concentrate it here so PR 2.2's service receives complete AlarmConditionInfo
+/// rows during discovery without the server needing to know the convention.
+///
+internal static class AlarmRefBuilder
+{
+ private const string InAlarmSuffix = ".InAlarm";
+ private const string PrioritySuffix = ".Priority";
+ private const string DescAttrNameSuffix = ".DescAttrName";
+ private const string AckedSuffix = ".Acked";
+ private const string AckMsgSuffix = ".AckMsg";
+
+ ///
+ /// 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.
+ ///
+ public static AlarmConditionInfo Build(
+ string fullReference,
+ AlarmSeverity initialSeverity = AlarmSeverity.Medium,
+ string? initialDescription = null) => new(
+ SourceName: fullReference,
+ InitialSeverity: initialSeverity,
+ InitialDescription: initialDescription,
+ InAlarmRef: fullReference + InAlarmSuffix,
+ PriorityRef: fullReference + PrioritySuffix,
+ DescAttrNameRef: fullReference + DescAttrNameSuffix,
+ AckedRef: fullReference + AckedSuffix,
+ AckMsgWriteRef: fullReference + AckMsgSuffix);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs
new file mode 100644
index 0000000..83456c5
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs
@@ -0,0 +1,23 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
+
+///
+/// Maps Galaxy mx_data_type integer codes to .
+/// Ported from the legacy GalaxyProxyDriver.MapDataType with the same fallback
+/// to for unknown codes — keeps wire compatibility
+/// with deployed configs while we tighten this through the parity matrix.
+///
+internal static class DataTypeMap
+{
+ public static DriverDataType Map(int mxDataType) => mxDataType switch
+ {
+ 0 => DriverDataType.Boolean,
+ 1 => DriverDataType.Int32,
+ 2 => DriverDataType.Float32,
+ 3 => DriverDataType.Float64,
+ 4 => DriverDataType.String,
+ 5 => DriverDataType.DateTime,
+ _ => DriverDataType.String,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs
new file mode 100644
index 0000000..7987956
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs
@@ -0,0 +1,80 @@
+using MxGateway.Contracts.Proto.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
+
+///
+/// Translates a Galaxy object hierarchy (from ) into
+/// calls — folders for each gobject, variables for
+/// each dynamic attribute. Alarm-bearing attributes get all five sub-attribute refs
+/// populated via so the server-level alarm subsystem
+/// (PR 2.2) can subscribe + ack without help from the driver.
+///
+///
+/// Hierarchy materialisation rules (mirror legacy MxAccessGalaxyBackend.DiscoverAsync):
+///
+/// - Browse name = contained_name when present; falls back to tag_name.
+/// - Folder per gobject; variables placed inside their owner folder.
+/// - Variable's full reference = tag_name.attribute_name — the format MXAccess
+/// expects for read/write addressing (translated from the contained-name browse path).
+/// - Hierarchy is rendered flat (one folder per gobject under the driver root) for
+/// this PR. PR 4.W's address-space wiring revisits whether to nest under
+/// parent_gobject_id for a true tree shape.
+///
+///
+public sealed class GalaxyDiscoverer
+{
+ private readonly IGalaxyHierarchySource _source;
+
+ public GalaxyDiscoverer(IGalaxyHierarchySource source)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+ }
+
+ ///
+ /// Drive the supplied builder with one folder + N variables per Galaxy object the
+ /// gateway returns. Idempotent — caller can re-invoke after a redeploy event.
+ ///
+ public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ var objects = await _source.GetHierarchyAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var obj in objects)
+ {
+ var browseName = string.IsNullOrEmpty(obj.ContainedName) ? obj.TagName : obj.ContainedName;
+ if (string.IsNullOrEmpty(browseName)) continue; // skip objects with no usable identity
+
+ var folder = builder.Folder(browseName, browseName);
+
+ foreach (var attr in obj.Attributes)
+ {
+ if (string.IsNullOrEmpty(attr.AttributeName)) continue;
+
+ var fullReference = !string.IsNullOrEmpty(attr.FullTagReference)
+ ? attr.FullTagReference
+ : obj.TagName + "." + attr.AttributeName;
+
+ var info = new DriverAttributeInfo(
+ FullName: fullReference,
+ DriverDataType: DataTypeMap.Map(attr.MxDataType),
+ IsArray: attr.IsArray,
+ ArrayDim: attr.IsArray && attr.ArrayDimensionPresent && attr.ArrayDimension > 0
+ ? (uint)attr.ArrayDimension
+ : null,
+ SecurityClass: SecurityMap.Map(attr.SecurityClassification),
+ IsHistorized: attr.IsHistorized,
+ IsAlarm: attr.IsAlarm);
+
+ 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.
+ if (attr.IsAlarm)
+ {
+ handle.MarkAsAlarmCondition(AlarmRefBuilder.Build(fullReference));
+ }
+ }
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyHierarchySource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyHierarchySource.cs
new file mode 100644
index 0000000..850fc7d
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GatewayGalaxyHierarchySource.cs
@@ -0,0 +1,21 @@
+using MxGateway.Client;
+using MxGateway.Contracts.Proto.Galaxy;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
+
+///
+/// Default wrapping the gateway's
+/// . Pages internally via the client's overload.
+///
+public sealed class GatewayGalaxyHierarchySource : IGalaxyHierarchySource
+{
+ private readonly GalaxyRepositoryClient _client;
+
+ public GatewayGalaxyHierarchySource(GalaxyRepositoryClient client)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ }
+
+ public Task> GetHierarchyAsync(CancellationToken cancellationToken)
+ => _client.DiscoverHierarchyAsync(cancellationToken);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/IGalaxyHierarchySource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/IGalaxyHierarchySource.cs
new file mode 100644
index 0000000..b81dfbe
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/IGalaxyHierarchySource.cs
@@ -0,0 +1,19 @@
+using MxGateway.Contracts.Proto.Galaxy;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
+
+///
+/// Driver-side seam between and the gateway. Production
+/// wraps GalaxyRepositoryClient; tests substitute a fake returning canned
+/// rows so the discoverer's translation logic can be exercised
+/// without a real gRPC channel.
+///
+public interface IGalaxyHierarchySource
+{
+ ///
+ /// Returns the full materialised Galaxy hierarchy. The gateway client pages
+ /// internally; this interface deliberately exposes only the post-paging shape so
+ /// callers don't reimplement paging.
+ ///
+ Task> GetHierarchyAsync(CancellationToken cancellationToken);
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs
new file mode 100644
index 0000000..2ec7129
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs
@@ -0,0 +1,25 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
+
+///
+/// Maps Galaxy security_classification integer codes to
+/// . Ported from the legacy
+/// GalaxyProxyDriver.MapSecurity; unknown codes fall back to
+/// so a forward-compatible Galaxy
+/// deployment with new classifications doesn't break discovery.
+///
+internal static class SecurityMap
+{
+ public static SecurityClassification Map(int mxSec) => mxSec switch
+ {
+ 0 => SecurityClassification.FreeAccess,
+ 1 => SecurityClassification.Operate,
+ 2 => SecurityClassification.SecuredWrite,
+ 3 => SecurityClassification.VerifiedWrite,
+ 4 => SecurityClassification.Tune,
+ 5 => SecurityClassification.Configure,
+ 6 => SecurityClassification.ViewOnly,
+ _ => SecurityClassification.FreeAccess,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
index e09fb9f..e1aadf3 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs
@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
+using MxGateway.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
@@ -20,12 +22,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
/// registers under driver-type name
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
///
-public sealed class GalaxyDriver : IDriver, IDisposable
+public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
{
private readonly string _driverInstanceId;
private readonly GalaxyDriverOptions _options;
private readonly ILogger _logger;
+ // PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
+ // lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
+ // first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
+ // GalaxyDiscoverer's translation logic without a real gRPC channel.
+ private IGalaxyHierarchySource? _hierarchySource;
+ private GalaxyRepositoryClient? _ownedRepositoryClient;
+
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
@@ -33,12 +42,27 @@ public sealed class GalaxyDriver : IDriver, IDisposable
string driverInstanceId,
GalaxyDriverOptions options,
ILogger? logger = null)
+ : this(driverInstanceId, options, hierarchySource: null, logger)
+ {
+ }
+
+ ///
+ /// Test-visible ctor — inject a custom so
+ /// can be exercised against canned hierarchies without
+ /// building a real gRPC channel.
+ ///
+ internal GalaxyDriver(
+ string driverInstanceId,
+ GalaxyDriverOptions options,
+ IGalaxyHierarchySource? hierarchySource,
+ ILogger? logger = null)
{
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
? driverInstanceId
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger.Instance;
+ _hierarchySource = hierarchySource;
}
///
@@ -93,10 +117,52 @@ public sealed class GalaxyDriver : IDriver, IDisposable
///
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ // ===== ITagDiscovery (PR 4.1) =====
+
+ ///
+ public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(builder);
+
+ var source = _hierarchySource ??= BuildDefaultHierarchySource();
+ var discoverer = new GalaxyDiscoverer(source);
+ await discoverer.DiscoverAsync(builder, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Lazily builds the default from
+ /// _options.Gateway. Owned is disposed in
+ /// . Tests bypass this by injecting their own source via the
+ /// internal ctor.
+ ///
+ private IGalaxyHierarchySource BuildDefaultHierarchySource()
+ {
+ var gw = _options.Gateway;
+ var clientOptions = new MxGatewayClientOptions
+ {
+ Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
+ // PR 4.1 stub: ApiKeySecretRef is currently treated as the literal API key.
+ // PR 4.W (or a follow-up) wires up DPAPI-backed secret resolution.
+ ApiKey = gw.ApiKeySecretRef,
+ UseTls = gw.UseTls,
+ CaCertificatePath = gw.CaCertificatePath,
+ ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
+ DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
+ StreamTimeout = gw.StreamTimeoutSeconds > 0
+ ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
+ : null,
+ };
+ _ownedRepositoryClient = GalaxyRepositoryClient.Create(clientOptions);
+ return new GatewayGalaxyHierarchySource(_ownedRepositoryClient);
+ }
+
public void Dispose()
{
if (_disposed) return;
_disposed = true;
- // No owned IDisposables until PR 4.2's GalaxyMxSession lands.
+ _ownedRepositoryClient?.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ _ownedRepositoryClient = null;
+ _hierarchySource = null;
}
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj
index 4a2812d..dec2b68 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj
@@ -15,6 +15,10 @@
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs
new file mode 100644
index 0000000..1585152
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Browse/GalaxyDiscovererTests.cs
@@ -0,0 +1,301 @@
+using MxGateway.Contracts.Proto.Galaxy;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
+
+///
+/// Tests 's translation of rows
+/// into calls. The fake builder records every Folder /
+/// Variable / MarkAsAlarmCondition call so assertions can target shape without booting
+/// a real OPC UA address space.
+///
+public sealed class GalaxyDiscovererTests
+{
+ private sealed class FakeHierarchySource(IReadOnlyList objects) : IGalaxyHierarchySource
+ {
+ public Task> GetHierarchyAsync(CancellationToken cancellationToken)
+ => Task.FromResult(objects);
+ }
+
+ private sealed record FolderCall(string BrowseName, string DisplayName);
+ private sealed record VariableCall(string FolderBrowseName, string AttributeName, DriverAttributeInfo Info);
+
+ private sealed class FakeBuilder : IAddressSpaceBuilder
+ {
+ public List Folders { get; } = [];
+ public List Variables { get; } = [];
+ public Dictionary AlarmDeclarations { get; } = [];
+
+ private readonly string? _currentFolder;
+
+ public FakeBuilder() : this(null) { }
+ private FakeBuilder(string? folder) { _currentFolder = folder; }
+
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ Folders.Add(new FolderCall(browseName, displayName));
+ // Return a child scoped to this folder; nested folders inherit the parent reference.
+ return new ChildBuilder(this, browseName);
+ }
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ var folder = _currentFolder ?? "";
+ Variables.Add(new VariableCall(folder, browseName, attributeInfo));
+ return new FakeVariableHandle(this, attributeInfo.FullName);
+ }
+
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
+
+ // Child folder routes Variable calls back to the parent's lists with its own scope.
+ private sealed class ChildBuilder(FakeBuilder parent, string folderBrowseName) : IAddressSpaceBuilder
+ {
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ parent.Folders.Add(new FolderCall(browseName, displayName));
+ return new ChildBuilder(parent, browseName);
+ }
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ parent.Variables.Add(new VariableCall(folderBrowseName, browseName, attributeInfo));
+ return new FakeVariableHandle(parent, attributeInfo.FullName);
+ }
+
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
+ }
+
+ private sealed class FakeVariableHandle(FakeBuilder owner, string fullRef) : IVariableHandle
+ {
+ public string FullReference { get; } = fullRef;
+
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ {
+ owner.AlarmDeclarations[FullReference] = info;
+ return new NoopSink();
+ }
+ }
+
+ private sealed class NoopSink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args) { }
+ }
+ }
+
+ private static GalaxyAttribute Attr(
+ string name, int mxDataType = 0, bool isArray = false, int arrayDim = 0,
+ int securityClass = 0, bool isHistorized = false, bool isAlarm = false,
+ string? fullTagReference = null)
+ {
+ var a = new GalaxyAttribute
+ {
+ AttributeName = name,
+ MxDataType = mxDataType,
+ IsArray = isArray,
+ ArrayDimension = arrayDim,
+ ArrayDimensionPresent = arrayDim > 0,
+ SecurityClassification = securityClass,
+ IsHistorized = isHistorized,
+ IsAlarm = isAlarm,
+ };
+ if (fullTagReference is not null) a.FullTagReference = fullTagReference;
+ return a;
+ }
+
+ private static GalaxyObject Obj(string tagName, string? containedName = null, params GalaxyAttribute[] attributes)
+ {
+ var o = new GalaxyObject
+ {
+ TagName = tagName,
+ ContainedName = containedName ?? tagName,
+ };
+ o.Attributes.AddRange(attributes);
+ return o;
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_BuildsOneFolderPerObject_AndOneVariablePerAttribute()
+ {
+ var src = new FakeHierarchySource([
+ Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2 /*Float32*/), Attr("SP", mxDataType: 2)),
+ Obj("Tank1_Pump", "Pump", Attr("Running", mxDataType: 0 /*Boolean*/)),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Folders.Count.ShouldBe(2);
+ builder.Folders[0].BrowseName.ShouldBe("Level");
+ builder.Folders[1].BrowseName.ShouldBe("Pump");
+ builder.Variables.Count.ShouldBe(3);
+ builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "PV");
+ builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "SP");
+ builder.Variables.ShouldContain(v => v.FolderBrowseName == "Pump" && v.AttributeName == "Running");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_FullReference_DefaultsToTagDotAttribute()
+ {
+ var src = new FakeHierarchySource([
+ Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2)),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Variables[0].Info.FullName.ShouldBe("Tank1_Level.PV");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_FullReference_PrefersGwSuppliedFullTagReference()
+ {
+ var src = new FakeHierarchySource([
+ Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2, fullTagReference: "explicit.full.ref")),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Variables[0].Info.FullName.ShouldBe("explicit.full.ref");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_BrowseName_FallsBackToTagName_WhenContainedEmpty()
+ {
+ var src = new FakeHierarchySource([
+ Obj("Tank1_Level", containedName: "", attributes: Attr("PV")),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Folders[0].BrowseName.ShouldBe("Tank1_Level");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_AttributeMetadata_PropagatesEveryField()
+ {
+ var src = new FakeHierarchySource([
+ Obj("T", "T", Attr("PV",
+ mxDataType: 3 /*Float64*/,
+ isArray: true, arrayDim: 16,
+ securityClass: 2 /*SecuredWrite*/,
+ isHistorized: true, isAlarm: false)),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ var info = builder.Variables[0].Info;
+ info.DriverDataType.ShouldBe(DriverDataType.Float64);
+ info.IsArray.ShouldBeTrue();
+ info.ArrayDim.ShouldBe(16u);
+ info.SecurityClass.ShouldBe(SecurityClassification.SecuredWrite);
+ info.IsHistorized.ShouldBeTrue();
+ info.IsAlarm.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_AlarmAttribute_PopulatesAllFiveSubAttributeRefs()
+ {
+ var src = new FakeHierarchySource([
+ Obj("Tank1_Level", "Level", Attr("HiHi", mxDataType: 0, isAlarm: true)),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.AlarmDeclarations.ShouldContainKey("Tank1_Level.HiHi");
+ var info = builder.AlarmDeclarations["Tank1_Level.HiHi"];
+ info.SourceName.ShouldBe("Tank1_Level.HiHi");
+ 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");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition()
+ {
+ var src = new FakeHierarchySource([
+ Obj("T", "T", Attr("PV", isAlarm: false), Attr("HiHi", isAlarm: true)),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.AlarmDeclarations.Count.ShouldBe(1);
+ builder.AlarmDeclarations.ShouldContainKey("T.HiHi");
+ builder.AlarmDeclarations.ShouldNotContainKey("T.PV");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_SkipsObjectsWithEmptyIdentity()
+ {
+ var src = new FakeHierarchySource([
+ new GalaxyObject { TagName = "", ContainedName = "" }, // skip
+ Obj("Real", "Real", Attr("PV")),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Folders.Count.ShouldBe(1);
+ builder.Folders[0].BrowseName.ShouldBe("Real");
+ }
+
+ [Fact]
+ public async Task DiscoverAsync_SkipsAttributesWithEmptyName()
+ {
+ var src = new FakeHierarchySource([
+ Obj("T", "T", new GalaxyAttribute { AttributeName = "", MxDataType = 0 }, Attr("PV")),
+ ]);
+ var discoverer = new GalaxyDiscoverer(src);
+ var builder = new FakeBuilder();
+
+ await discoverer.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Variables.Count.ShouldBe(1);
+ builder.Variables[0].AttributeName.ShouldBe("PV");
+ }
+
+ [Fact]
+ public async Task DriverDiscoverAsync_RoutesThroughInjectedSource()
+ {
+ var src = new FakeHierarchySource([Obj("T", "T", Attr("PV"))]);
+ var driver = new GalaxyDriverHelper().CreateWithFakeSource(src);
+ var builder = new FakeBuilder();
+
+ await driver.DiscoverAsync(builder, CancellationToken.None);
+
+ builder.Folders.Count.ShouldBe(1);
+ builder.Variables.Count.ShouldBe(1);
+ }
+
+ /// Helper that exercises the internal ctor (test seam) without exposing it publicly.
+ private sealed class GalaxyDriverHelper
+ {
+ public GalaxyDriver CreateWithFakeSource(IGalaxyHierarchySource source)
+ => new GalaxyDriver(
+ "galaxy-test",
+ new Config.GalaxyDriverOptions(
+ new Config.GalaxyGatewayOptions("https://x", "k"),
+ new Config.GalaxyMxAccessOptions("OtOpcUa-T"),
+ new Config.GalaxyRepositoryOptions(),
+ new Config.GalaxyReconnectOptions()),
+ source,
+ logger: null);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj
index e5c3a06..d63966c 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj
@@ -21,6 +21,9 @@
+
+