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:
Joseph Doherty
2026-04-29 15:06:02 -04:00
parent f6a4f919e2
commit ecba5cedf9
10 changed files with 595 additions and 2 deletions

View File

@@ -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>&lt;tag&gt;.&lt;attr&gt;.InAlarm</c></item>
/// <item><c>&lt;tag&gt;.&lt;attr&gt;.Priority</c></item>
/// <item><c>&lt;tag&gt;.&lt;attr&gt;.DescAttrName</c></item>
/// <item><c>&lt;tag&gt;.&lt;attr&gt;.Acked</c></item>
/// <item><c>&lt;tag&gt;.&lt;attr&gt;.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);
}

View 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,
};
}

View File

@@ -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));
}
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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,
};
}