PR 4.1 — ITagDiscovery via GalaxyRepositoryClient + AlarmRefBuilder
Browse path online. GalaxyDriver now implements ITagDiscovery against the gateway's GalaxyRepositoryClient (PR 0.1's mxaccessgw browse RPC) and feeds the address-space builder one folder per gobject + one variable per dynamic attribute, with alarm-bearing attributes carrying all five sub-attribute refs the server-level AlarmConditionService (PR 2.2) needs. Files: - Browse/IGalaxyHierarchySource.cs — driver-side seam between the discoverer and the gateway. Test fakes return canned hierarchies so the discoverer's translation logic is exercised without a real gRPC channel. - Browse/GatewayGalaxyHierarchySource.cs — production wrapper around GalaxyRepositoryClient.DiscoverHierarchyAsync (paged internally). - Browse/GalaxyDiscoverer.cs — translates GalaxyObject → IAddressSpaceBuilder calls. Browse name = contained_name (falls back to tag_name); full reference = attr.full_tag_reference when set, else tag_name + "." + attribute_name. Skips objects/attributes with empty identity. - Browse/DataTypeMap.cs — mx_data_type → DriverDataType (port from legacy GalaxyProxyDriver.MapDataType, same fallback to String for unknown codes). - Browse/SecurityMap.cs — security_classification → SecurityClassification (port from legacy GalaxyProxyDriver.MapSecurity). - Browse/AlarmRefBuilder.cs — populates the five sub-attribute refs by Galaxy convention (.InAlarm/.Priority/.DescAttrName/.Acked/.AckMsg). The same convention the legacy GalaxyAlarmTracker hard-coded; concentrated here so PR 2.2's service receives complete AlarmConditionInfo rows. GalaxyDriver: - Added internal ctor accepting IGalaxyHierarchySource? for test injection. Default lazily builds GatewayGalaxyHierarchySource around a GalaxyRepositoryClient constructed from options on first DiscoverAsync. - Owned GalaxyRepositoryClient disposed in Dispose. - ApiKey resolution is currently a passthrough of ApiKeySecretRef — PR 4.W (or follow-up) wires DPAPI-backed secret resolution. csproj: path-based ProjectReference to mxaccessgw (the user is shipping that repo on a parallel track; both repos sit side-by-side on the dev box). Tests project also references MxGateway.Contracts directly to construct GalaxyObject / GalaxyAttribute fixtures. Tests: 10 new in Browse/GalaxyDiscovererTests.cs covering folder-per-object, variable-per-attribute, full-ref defaulting + gw-supplied override, browse- name fallback, every metadata field propagation, alarm sub-attribute ref population, non-alarm rows skip MarkAsAlarmCondition, empty-identity skips, empty-attribute-name skips, end-to-end through GalaxyDriver.DiscoverAsync. 20 total Galaxy tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populates the five sub-attribute references on <see cref="AlarmConditionInfo"/>
|
||||||
|
/// by Galaxy convention. The server-level <c>AlarmConditionService</c> (PR 2.2) uses
|
||||||
|
/// these to subscribe to live alarm-state attributes and to route ack writes back to
|
||||||
|
/// the alarm tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Galaxy alarms expose four runtime attributes plus a write-only ack target,
|
||||||
|
/// consistently named on every alarm-bearing object:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c><tag>.<attr>.InAlarm</c></item>
|
||||||
|
/// <item><c><tag>.<attr>.Priority</c></item>
|
||||||
|
/// <item><c><tag>.<attr>.DescAttrName</c></item>
|
||||||
|
/// <item><c><tag>.<attr>.Acked</c></item>
|
||||||
|
/// <item><c><tag>.<attr>.AckMsg</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// This is the same convention the legacy <c>GalaxyAlarmTracker</c> hard-coded; we
|
||||||
|
/// concentrate it here so PR 2.2's service receives complete <c>AlarmConditionInfo</c>
|
||||||
|
/// rows during discovery without the server needing to know the convention.
|
||||||
|
/// </remarks>
|
||||||
|
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";
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
23
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DataTypeMap.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Galaxy <c>mx_data_type</c> integer codes to <see cref="DriverDataType"/>.
|
||||||
|
/// Ported from the legacy <c>GalaxyProxyDriver.MapDataType</c> with the same fallback
|
||||||
|
/// to <see cref="DriverDataType.String"/> for unknown codes — keeps wire compatibility
|
||||||
|
/// with deployed configs while we tighten this through the parity matrix.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates a Galaxy object hierarchy (from <see cref="IGalaxyHierarchySource"/>) into
|
||||||
|
/// <see cref="IAddressSpaceBuilder"/> calls — folders for each gobject, variables for
|
||||||
|
/// each dynamic attribute. Alarm-bearing attributes get all five sub-attribute refs
|
||||||
|
/// populated via <see cref="AlarmRefBuilder"/> so the server-level alarm subsystem
|
||||||
|
/// (PR 2.2) can subscribe + ack without help from the driver.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Hierarchy materialisation rules (mirror legacy <c>MxAccessGalaxyBackend.DiscoverAsync</c>):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Browse name = <c>contained_name</c> when present; falls back to <c>tag_name</c>.</item>
|
||||||
|
/// <item>Folder per gobject; variables placed inside their owner folder.</item>
|
||||||
|
/// <item>Variable's full reference = <c>tag_name.attribute_name</c> — the format MXAccess
|
||||||
|
/// expects for read/write addressing (translated from the contained-name browse path).</item>
|
||||||
|
/// <item>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
|
||||||
|
/// <c>parent_gobject_id</c> for a true tree shape.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class GalaxyDiscoverer
|
||||||
|
{
|
||||||
|
private readonly IGalaxyHierarchySource _source;
|
||||||
|
|
||||||
|
public GalaxyDiscoverer(IGalaxyHierarchySource source)
|
||||||
|
{
|
||||||
|
_source = source ?? throw new ArgumentNullException(nameof(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drive the supplied builder with one folder + N variables per Galaxy object the
|
||||||
|
/// gateway returns. Idempotent — caller can re-invoke after a redeploy event.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using MxGateway.Client;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default <see cref="IGalaxyHierarchySource"/> wrapping the gateway's
|
||||||
|
/// <see cref="GalaxyRepositoryClient"/>. Pages internally via the client's overload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayGalaxyHierarchySource : IGalaxyHierarchySource
|
||||||
|
{
|
||||||
|
private readonly GalaxyRepositoryClient _client;
|
||||||
|
|
||||||
|
public GatewayGalaxyHierarchySource(GalaxyRepositoryClient client)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
|
||||||
|
=> _client.DiscoverHierarchyAsync(cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-side seam between <see cref="GalaxyDiscoverer"/> and the gateway. Production
|
||||||
|
/// wraps <c>GalaxyRepositoryClient</c>; tests substitute a fake returning canned
|
||||||
|
/// <see cref="GalaxyObject"/> rows so the discoverer's translation logic can be exercised
|
||||||
|
/// without a real gRPC channel.
|
||||||
|
/// </summary>
|
||||||
|
public interface IGalaxyHierarchySource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
25
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/SecurityMap.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Galaxy <c>security_classification</c> integer codes to
|
||||||
|
/// <see cref="SecurityClassification"/>. Ported from the legacy
|
||||||
|
/// <c>GalaxyProxyDriver.MapSecurity</c>; unknown codes fall back to
|
||||||
|
/// <see cref="SecurityClassification.FreeAccess"/> so a forward-compatible Galaxy
|
||||||
|
/// deployment with new classifications doesn't break discovery.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MxGateway.Client;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
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.Config;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
@@ -20,12 +22,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
|||||||
/// <see cref="GalaxyDriverFactoryExtensions"/> registers under driver-type name
|
/// <see cref="GalaxyDriverFactoryExtensions"/> registers under driver-type name
|
||||||
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
|
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class GalaxyDriver : IDriver, IDisposable
|
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
private readonly GalaxyDriverOptions _options;
|
private readonly GalaxyDriverOptions _options;
|
||||||
private readonly ILogger<GalaxyDriver> _logger;
|
private readonly ILogger<GalaxyDriver> _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 DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
@@ -33,12 +42,27 @@ public sealed class GalaxyDriver : IDriver, IDisposable
|
|||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null)
|
||||||
|
: this(driverInstanceId, options, hierarchySource: null, logger)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-visible ctor — inject a custom <see cref="IGalaxyHierarchySource"/> so
|
||||||
|
/// <see cref="DiscoverAsync"/> can be exercised against canned hierarchies without
|
||||||
|
/// building a real gRPC channel.
|
||||||
|
/// </summary>
|
||||||
|
internal GalaxyDriver(
|
||||||
|
string driverInstanceId,
|
||||||
|
GalaxyDriverOptions options,
|
||||||
|
IGalaxyHierarchySource? hierarchySource,
|
||||||
|
ILogger<GalaxyDriver>? logger = null)
|
||||||
{
|
{
|
||||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||||
? driverInstanceId
|
? driverInstanceId
|
||||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||||
|
_hierarchySource = hierarchySource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -93,10 +117,52 @@ public sealed class GalaxyDriver : IDriver, IDisposable
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
// ===== ITagDiscovery (PR 4.1) =====
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazily builds the default <see cref="IGalaxyHierarchySource"/> from
|
||||||
|
/// <c>_options.Gateway</c>. Owned <see cref="GalaxyRepositoryClient"/> is disposed in
|
||||||
|
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
||||||
|
/// internal ctor.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
// No owned IDisposables until PR 4.2's GalaxyMxSession lands.
|
_ownedRepositoryClient?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
_ownedRepositoryClient = null;
|
||||||
|
_hierarchySource = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<!-- mxaccessgw .NET client. Path-based ProjectReference because both repos sit
|
||||||
|
side-by-side on the dev box; long-term we'll consume MxGateway.Client as a
|
||||||
|
NuGet package. PR 4.W revisits the dependency shape before parity gating. -->
|
||||||
|
<ProjectReference Include="..\..\..\mxaccessgw\clients\dotnet\MxGateway.Client\MxGateway.Client.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests <see cref="GalaxyDiscoverer"/>'s translation of <see cref="GalaxyObject"/> rows
|
||||||
|
/// into <see cref="IAddressSpaceBuilder"/> calls. The fake builder records every Folder /
|
||||||
|
/// Variable / MarkAsAlarmCondition call so assertions can target shape without booting
|
||||||
|
/// a real OPC UA address space.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyDiscovererTests
|
||||||
|
{
|
||||||
|
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyList<GalaxyObject>> 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<FolderCall> Folders { get; } = [];
|
||||||
|
public List<VariableCall> Variables { get; } = [];
|
||||||
|
public Dictionary<string, AlarmConditionInfo> 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 ?? "<root>";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Helper that exercises the internal ctor (test seam) without exposing it publicly.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||||
|
<!-- Pulled in transitively via Driver.Galaxy → MxGateway.Client → MxGateway.Contracts;
|
||||||
|
explicit reference lets tests construct GalaxyObject / GalaxyAttribute fixtures. -->
|
||||||
|
<ProjectReference Include="..\..\..\mxaccessgw\src\MxGateway.Contracts\MxGateway.Contracts.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user