docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -36,6 +36,9 @@ internal static class AlarmRefBuilder
/// attribute's full reference (e.g. <c>"Tank1.Level.HiHi"</c>); the convention prefixes
/// each suffix to it.
/// </summary>
/// <param name="fullReference">The full reference of the alarm-bearing attribute.</param>
/// <param name="initialSeverity">The initial alarm severity level.</param>
/// <param name="initialDescription">The initial alarm description.</param>
public static AlarmConditionInfo Build(
string fullReference,
AlarmSeverity initialSeverity = AlarmSeverity.Medium,
@@ -18,6 +18,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
/// </remarks>
internal static class DataTypeMap
{
/// <summary>Maps an MXAccess data type ID to a driver data type.</summary>
/// <param name="mxDataType">The MXAccess data type ID.</param>
public static DriverDataType Map(int mxDataType) => mxDataType switch
{
0 => DriverDataType.Boolean,
@@ -46,6 +46,9 @@ public sealed class DeployWatcher : IDisposable
/// <inheritdoc cref="IRediscoverable.OnRediscoveryNeeded"/>
public event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
/// <summary>Initializes a new deploy watcher with default backoff parameters.</summary>
/// <param name="source">The deploy watch source to subscribe to.</param>
/// <param name="logger">Optional logger instance; defaults to null logger.</param>
public DeployWatcher(IGalaxyDeployWatchSource source, ILogger? logger = null)
: this(source, logger, DefaultInitialBackoff, DefaultMaxBackoff, jitter: null)
{
@@ -55,6 +58,11 @@ public sealed class DeployWatcher : IDisposable
/// Test-only ctor lets tests collapse the retry backoff so a fault-injection
/// scenario doesn't sit in <see cref="Task.Delay(TimeSpan, CancellationToken)"/>.
/// </summary>
/// <param name="source">The deploy watch source to subscribe to.</param>
/// <param name="logger">Optional logger instance.</param>
/// <param name="initialBackoff">Initial backoff duration for retry.</param>
/// <param name="maxBackoff">Maximum backoff duration for retry.</param>
/// <param name="jitter">Optional function to apply jitter to backoff intervals.</param>
internal DeployWatcher(
IGalaxyDeployWatchSource source,
ILogger? logger,
@@ -74,6 +82,8 @@ public sealed class DeployWatcher : IDisposable
/// has been scheduled — the loop itself runs until <see cref="StopAsync"/> or
/// the supplied <paramref name="cancellationToken"/> is signaled.
/// </summary>
/// <param name="cancellationToken">Token to signal cancellation of the watch loop.</param>
/// <returns>A task that completes when the loop has been scheduled.</returns>
public Task StartAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _started, 1) != 0)
@@ -112,6 +122,7 @@ public sealed class DeployWatcher : IDisposable
}
}
/// <summary>Disposes the watcher and stops the background loop.</summary>
public void Dispose()
{
if (_loopTask is null) return;
@@ -26,6 +26,8 @@ public sealed class GalaxyDiscoverer
{
private readonly IGalaxyHierarchySource _source;
/// <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)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
@@ -35,6 +37,8 @@ public sealed class GalaxyDiscoverer
/// 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>
/// <param name="builder">The address space builder to populate with discovery results.</param>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -15,11 +15,17 @@ public sealed class GatewayGalaxyDeployWatchSource : IGalaxyDeployWatchSource
{
private readonly GalaxyRepositoryClient _client;
/// <summary>Initializes a new instance of the GatewayGalaxyDeployWatchSource class.</summary>
/// <param name="client">The Galaxy repository client.</param>
public GatewayGalaxyDeployWatchSource(GalaxyRepositoryClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <summary>Watches for deploy events asynchronously.</summary>
/// <param name="lastSeenDeployTime">The last deploy time that was observed.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of deploy events.</returns>
public IAsyncEnumerable<DeployEvent> WatchAsync(
DateTimeOffset? lastSeenDeployTime, CancellationToken cancellationToken)
=> _client.WatchDeployEventsAsync(lastSeenDeployTime, cancellationToken);
@@ -11,11 +11,19 @@ public sealed class GatewayGalaxyHierarchySource : IGalaxyHierarchySource
{
private readonly GalaxyRepositoryClient _client;
/// <summary>
/// Initializes a new instance of the <see cref="GatewayGalaxyHierarchySource"/> class.
/// </summary>
/// <param name="client">The gateway's Galaxy repository client for discovering the object hierarchy.</param>
public GatewayGalaxyHierarchySource(GalaxyRepositoryClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <summary>
/// Discovers the Galaxy object hierarchy asynchronously via the gateway.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
=> _client.DiscoverHierarchyAsync(cancellationToken);
}
@@ -19,6 +19,8 @@ public interface IGalaxyDeployWatchSource
/// <see cref="DeployWatcher"/> still suppresses the first event it observes locally
/// so a transport reconnect doesn't re-fire on identical state.
/// </summary>
/// <param name="lastSeenDeployTime">The last seen deploy time, or null to receive a bootstrap event.</param>
/// <param name="cancellationToken">The cancellation token.</param>
IAsyncEnumerable<DeployEvent> WatchAsync(
DateTimeOffset? lastSeenDeployTime, CancellationToken cancellationToken);
}
@@ -15,5 +15,6 @@ public interface IGalaxyHierarchySource
/// internally; this interface deliberately exposes only the post-paging shape so
/// callers don't reimplement paging.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken);
}
@@ -11,6 +11,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
/// </summary>
internal static class SecurityMap
{
/// <summary>Maps a Galaxy security_classification code to a SecurityClassification enum.</summary>
/// <param name="mxSec">The Galaxy security classification code.</param>
/// <returns>The corresponding SecurityClassification.</returns>
public static SecurityClassification Map(int mxSec) => mxSec switch
{
0 => SecurityClassification.FreeAccess,
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
/// </summary>
internal sealed class TracedGalaxyHierarchySource(IGalaxyHierarchySource inner, string clientName) : IGalaxyHierarchySource
{
/// <inheritdoc />
public async Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
{
using var activity = GalaxyTelemetry.ActivitySource.StartActivity("galaxy.get_hierarchy");
@@ -129,6 +129,10 @@ public sealed class GalaxyDriver
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="options">The Galaxy driver configuration options.</param>
/// <param name="logger">Optional logger instance for diagnostics.</param>
public GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
@@ -145,6 +149,15 @@ public sealed class GalaxyDriver
/// <see cref="SubscribeAsync"/> can be exercised against canned data without
/// building real gRPC channels.
/// </summary>
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="options">The Galaxy driver configuration options.</param>
/// <param name="hierarchySource">Optional custom hierarchy source for testing.</param>
/// <param name="dataReader">Optional custom data reader for testing.</param>
/// <param name="dataWriter">Optional custom data writer for testing.</param>
/// <param name="subscriber">Optional custom subscriber for testing.</param>
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
/// <param name="logger">Optional logger instance for diagnostics.</param>
internal GalaxyDriver(
string driverInstanceId,
GalaxyDriverOptions options,
@@ -188,6 +201,7 @@ public sealed class GalaxyDriver
/// <see cref="GalaxyReconnectOptions.ReplayOnSessionLost"/> branch can be
/// asserted deterministically (Driver.Galaxy-013).
/// </summary>
/// <param name="cancellationToken">Cancellation token for the replay operation.</param>
internal Task InvokeReplayForTestAsync(CancellationToken cancellationToken) =>
ReplayAsync(cancellationToken);
@@ -434,6 +448,7 @@ public sealed class GalaxyDriver
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
/// changing the call site.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
internal static string ResolveApiKey(string secretRef) => ResolveApiKey(secretRef, logger: null);
/// <summary>
@@ -442,6 +457,8 @@ public sealed class GalaxyDriver
/// API key in <c>DriverConfig</c> JSON). The <c>dev:</c> prefix is the explicit
/// opt-in path that doesn't warn.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
internal static string ResolveApiKey(string secretRef, ILogger? logger)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
@@ -1261,15 +1278,26 @@ public sealed class GalaxyDriver
System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification> map)
: IAddressSpaceBuilder
{
/// <summary>Creates a folder node and returns a builder for populating it.</summary>
/// <param name="browseName">The OPC UA BrowseName of the folder.</param>
/// <param name="displayName">The display name for the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new SecurityCapturingBuilder(inner.Folder(browseName, displayName), map);
/// <summary>Creates a variable node and captures its security classification.</summary>
/// <param name="browseName">The OPC UA BrowseName of the variable.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="attributeInfo">The driver attribute metadata including security classification.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
map[attributeInfo.FullName] = attributeInfo.SecurityClass;
return inner.Variable(browseName, displayName, attributeInfo);
}
/// <summary>Adds a property node to the current parent.</summary>
/// <param name="browseName">The OPC UA BrowseName of the property.</param>
/// <param name="dataType">The OPC UA data type of the property.</param>
/// <param name="value">The property value.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> inner.AddProperty(browseName, dataType, value);
}
@@ -23,6 +23,9 @@ public static class GalaxyDriverFactoryExtensions
{
public const string DriverTypeName = "GalaxyMxGateway";
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
{
ArgumentNullException.ThrowIfNull(registry);
@@ -30,9 +33,15 @@ public static class GalaxyDriverFactoryExtensions
}
/// <summary>Convenience for tests + standalone callers.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
public static GalaxyDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
{
@@ -81,43 +90,68 @@ public static class GalaxyDriverFactoryExtensions
AllowTrailingCommas = true,
};
/// <summary>Data transfer object for Galaxy driver configuration JSON.</summary>
internal sealed class GalaxyDriverConfigDto
{
/// <summary>Gets or sets the gateway configuration.</summary>
public GatewayDto? Gateway { get; init; }
/// <summary>Gets or sets the MX Access configuration.</summary>
public MxAccessDto? MxAccess { get; init; }
/// <summary>Gets or sets the repository configuration.</summary>
public RepositoryDto? Repository { get; init; }
/// <summary>Gets or sets the reconnect configuration.</summary>
public ReconnectDto? Reconnect { get; init; }
}
/// <summary>Gateway configuration section.</summary>
internal sealed class GatewayDto
{
/// <summary>Gets or sets the gateway endpoint address.</summary>
public string? Endpoint { get; init; }
/// <summary>Gets or sets the API key secret reference.</summary>
public string? ApiKeySecretRef { get; init; }
/// <summary>Gets or sets whether to use TLS.</summary>
public bool? UseTls { get; init; }
/// <summary>Gets or sets the CA certificate path.</summary>
public string? CaCertificatePath { get; init; }
/// <summary>Gets or sets the connection timeout in seconds.</summary>
public int? ConnectTimeoutSeconds { get; init; }
/// <summary>Gets or sets the default call timeout in seconds.</summary>
public int? DefaultCallTimeoutSeconds { get; init; }
/// <summary>Gets or sets the stream timeout in seconds.</summary>
public int? StreamTimeoutSeconds { get; init; }
}
/// <summary>MX Access configuration section.</summary>
internal sealed class MxAccessDto
{
/// <summary>Gets or sets the client name.</summary>
public string? ClientName { get; init; }
/// <summary>Gets or sets the publishing interval in milliseconds.</summary>
public int? PublishingIntervalMs { get; init; }
/// <summary>Gets or sets the write user ID.</summary>
public int? WriteUserId { get; init; }
/// <summary>Gets or sets the event pump channel capacity.</summary>
public int? EventPumpChannelCapacity { get; init; }
}
/// <summary>Repository configuration section.</summary>
internal sealed class RepositoryDto
{
/// <summary>Gets or sets the discover page size.</summary>
public int? DiscoverPageSize { get; init; }
/// <summary>Gets or sets whether to watch deploy events.</summary>
public bool? WatchDeployEvents { get; init; }
}
/// <summary>Reconnect configuration section.</summary>
internal sealed class ReconnectDto
{
/// <summary>Gets or sets the initial backoff in milliseconds.</summary>
public int? InitialBackoffMs { get; init; }
/// <summary>Gets or sets the maximum backoff in milliseconds.</summary>
public int? MaxBackoffMs { get; init; }
/// <summary>Gets or sets whether to replay on session lost.</summary>
public bool? ReplayOnSessionLost { get; init; }
}
}
@@ -27,6 +27,10 @@ public sealed class HostConnectivityForwarder : IDisposable
private readonly ILogger _logger;
private bool _disposed;
/// <summary>Initializes a new instance of HostConnectivityForwarder with the given client name, aggregator, and optional logger.</summary>
/// <param name="clientName">The client name for the MxAccess connection.</param>
/// <param name="aggregator">The host status aggregator to push connectivity state to.</param>
/// <param name="logger">The optional logger for diagnostic messages.</param>
public HostConnectivityForwarder(string clientName, HostStatusAggregator aggregator, ILogger? logger = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clientName);
@@ -39,6 +43,7 @@ public sealed class HostConnectivityForwarder : IDisposable
/// Push a transport state into the aggregator. Idempotent at the aggregator layer —
/// repeated calls with the same state don't fan out duplicate transitions.
/// </summary>
/// <param name="state">The host connectivity state to push.</param>
public void SetTransport(HostState state)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -49,6 +54,7 @@ public sealed class HostConnectivityForwarder : IDisposable
_clientName, state);
}
/// <summary>Disposes the forwarder and marks it as disposed.</summary>
public void Dispose()
{
// No-op today; reserved for the eventual gw-6 StreamSessionHealth consumer that
@@ -53,6 +53,7 @@ public sealed class HostStatusAggregator
/// state value differs from the last cached entry. Re-asserting the same
/// state is silent.
/// </summary>
/// <param name="status">The host connectivity status to upsert.</param>
public void Update(HostConnectivityStatus status)
{
ArgumentNullException.ThrowIfNull(status);
@@ -87,6 +88,8 @@ public sealed class HostStatusAggregator
/// is fired — observers only react to live transitions, not topology
/// reductions. Returns <c>true</c> when the host was tracked.
/// </summary>
/// <param name="hostName">The name of the host to remove.</param>
/// <returns>True if the host was tracked and removed; false if it was not tracked.</returns>
public bool Remove(string hostName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hostName);
@@ -45,6 +45,11 @@ public sealed class PerPlatformProbeWatcher : IDisposable
private readonly Lock _syncLock = new();
private bool _disposed;
/// <summary>Initializes a new instance of the PerPlatformProbeWatcher class.</summary>
/// <param name="subscriber">The Galaxy subscriber for managing probe subscriptions.</param>
/// <param name="aggregator">The host status aggregator for tracking platform connectivity.</param>
/// <param name="logger">Optional logger for diagnostic messages.</param>
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds; must be >= 0.</param>
public PerPlatformProbeWatcher(
IGalaxySubscriber subscriber,
HostStatusAggregator aggregator,
@@ -70,6 +75,8 @@ public sealed class PerPlatformProbeWatcher : IDisposable
/// Subscribes new entries, unsubscribes dropped ones. Calling with the same set is
/// a no-op.
/// </summary>
/// <param name="platformTagNames">The platform tag names to synchronize.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
public async Task SyncPlatformsAsync(
IEnumerable<string> platformTagNames, CancellationToken cancellationToken)
{
@@ -149,6 +156,9 @@ public sealed class PerPlatformProbeWatcher : IDisposable
/// <see cref="ProbeSuffix"/>, or a probe for a platform we're not tracking) are
/// silently dropped.
/// </summary>
/// <param name="fullReference">The full reference path of the probe attribute.</param>
/// <param name="value">The probe value to decode.</param>
/// <param name="qualityByte">The quality byte for the value.</param>
public void OnProbeValueChanged(string fullReference, object? value, byte qualityByte)
{
if (_disposed) return;
@@ -166,6 +176,9 @@ public sealed class PerPlatformProbeWatcher : IDisposable
/// Decode a ScanState value + raw quality byte to a <see cref="HostState"/>.
/// Public for tests that want to pin the decoding table.
/// </summary>
/// <param name="value">The probe value to decode.</param>
/// <param name="qualityByte">The quality byte for the value.</param>
/// <returns>The decoded host state.</returns>
public static HostState DecodeState(object? value, byte qualityByte)
{
if (qualityByte < 192) return HostState.Unknown;
@@ -182,6 +195,7 @@ public sealed class PerPlatformProbeWatcher : IDisposable
};
}
/// <summary>Disposes the probe watcher and unsubscribes all tracked platforms.</summary>
public void Dispose()
{
if (_disposed) return;
@@ -61,8 +61,17 @@ internal sealed class EventPump : IAsyncDisposable
private Task? _dispatchLoop;
private bool _disposed;
/// <summary>Occurs when a data change event is received from the Galaxy subscriber.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Initializes a new instance of the EventPump class.</summary>
/// <param name="subscriber">The Galaxy subscriber to consume events from.</param>
/// <param name="registry">The subscription registry for resolving subscribers.</param>
/// <param name="logger">The logger instance; if null, uses NullLogger.</param>
/// <param name="handleFactory">The factory for creating subscription handles; if null, uses GalaxySubscriptionHandle.</param>
/// <param name="channelCapacity">The bounded channel capacity for buffering events.</param>
/// <param name="clientName">The client name for metric tagging; if null, uses "&lt;unknown&gt;".</param>
/// <param name="onStreamFault">Optional callback invoked when the stream faults.</param>
public EventPump(
IGalaxySubscriber subscriber,
SubscriptionRegistry registry,
@@ -234,6 +243,8 @@ internal sealed class EventPump : IAsyncDisposable
ServerTimestampUtc: DateTime.UtcNow);
}
/// <summary>Disposes the event pump and cancels all running tasks.</summary>
/// <returns>A value task representing the asynchronous disposal operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// </summary>
internal sealed class GalaxyAlarmSubscriptionHandle : IAlarmSubscriptionHandle
{
/// <summary>Initializes a new instance of the GalaxyAlarmSubscriptionHandle class.</summary>
/// <param name="diagnosticId">The diagnostic ID for the subscription.</param>
public GalaxyAlarmSubscriptionHandle(string diagnosticId)
{
DiagnosticId = diagnosticId;
@@ -30,12 +30,16 @@ public sealed class GalaxyMxSession : IAsyncDisposable
private int _serverHandle;
private bool _disposed;
/// <summary>Initializes a new instance of the GalaxyMxSession class.</summary>
/// <param name="options">MX Access configuration options.</param>
/// <param name="logger">Optional logger instance; uses NullLogger if not provided.</param>
public GalaxyMxSession(GalaxyMxAccessOptions options, ILogger? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Gets a value indicating whether the session is connected.</summary>
public bool IsConnected => _session is not null;
/// <summary>
@@ -49,6 +53,8 @@ public sealed class GalaxyMxSession : IAsyncDisposable
/// configured client name. Idempotent — second calls are no-ops while
/// <see cref="IsConnected"/> is true.
/// </summary>
/// <param name="clientOptions">The MX gateway client options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task ConnectAsync(MxGatewayClientOptions clientOptions, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -67,6 +73,8 @@ public sealed class GalaxyMxSession : IAsyncDisposable
/// fake). Skips the gateway-client construction so tests can drive the session
/// surface without spinning a real gRPC channel. Caller retains client ownership.
/// </summary>
/// <param name="session">The MX gateway session to attach.</param>
/// <param name="serverHandle">The server handle value to use.</param>
internal void AttachForTests(MxGatewaySession session, int serverHandle)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -80,6 +88,7 @@ public sealed class GalaxyMxSession : IAsyncDisposable
/// </summary>
public MxGatewaySession? Session => _session;
/// <summary>Disposes the session and underlying gateway client resources.</summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
@@ -8,5 +8,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// </summary>
internal sealed record GalaxySubscriptionHandle(long SubscriptionId) : ISubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for the subscription.</summary>
public string DiagnosticId => $"galaxy-sub-{SubscriptionId}";
}
@@ -25,6 +25,8 @@ internal static class GalaxyTelemetry
/// Tag a span with a failure reason and set its status to <c>Error</c>. Helper
/// so the decorators don't repeat the four-line idiom on every catch block.
/// </summary>
/// <param name="activity">The activity to tag with error information.</param>
/// <param name="ex">The exception to record.</param>
public static void RecordError(this Activity? activity, Exception ex)
{
if (activity is null) return;
@@ -23,12 +23,20 @@ internal sealed class GatewayGalaxyAlarmAcknowledger : IGalaxyAlarmAcknowledger
private readonly MxGatewayClient _client;
private readonly ILogger _logger;
/// <summary>Initializes a new instance of the <see cref="GatewayGalaxyAlarmAcknowledger"/> class.</summary>
/// <param name="client">The MX gateway client used to send acknowledgments.</param>
/// <param name="logger">A logger for diagnostic output.</param>
public GatewayGalaxyAlarmAcknowledger(MxGatewayClient client, ILogger logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Acknowledges an alarm via the gateway.</summary>
/// <param name="alarmFullReference">The full reference path of the alarm to acknowledge.</param>
/// <param name="comment">An operator-supplied comment attached to the acknowledgement.</param>
/// <param name="operatorUser">The name of the operator performing the acknowledgement.</param>
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
public async Task AcknowledgeAsync(
string alarmFullReference,
string comment,
@@ -37,6 +37,8 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
/// Opens a <c>StreamAlarms</c> feed. Matches the method group
/// <c>MxGatewayClient.StreamAlarmsAsync</c>.
/// </summary>
/// <param name="request">The stream request parameters.</param>
/// <param name="cancellationToken">A cancellation token.</param>
internal delegate IAsyncEnumerable<AlarmFeedMessage> AlarmStreamFactory(
StreamAlarmsRequest request, CancellationToken cancellationToken);
@@ -65,8 +67,15 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
private Task? _loop;
private bool _disposed;
/// <summary>Occurs when an alarm transition (raise, acknowledge, clear) is received from the Galaxy feed.</summary>
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
/// <summary>Initializes a new instance of the <see cref="GatewayGalaxyAlarmFeed"/> class.</summary>
/// <param name="streamFactory">A factory delegate that opens the alarm stream.</param>
/// <param name="logger">An optional logger for diagnostic output.</param>
/// <param name="clientName">An optional client name for tagging log entries.</param>
/// <param name="alarmFilterPrefix">An optional prefix to filter alarms in the stream.</param>
/// <param name="reconnectDelay">An optional delay before reconnecting after a stream fault.</param>
public GatewayGalaxyAlarmFeed(
AlarmStreamFactory streamFactory,
ILogger? logger = null,
@@ -81,6 +90,7 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
_clientTag = new KeyValuePair<string, object?>("galaxy.client", clientName ?? "<unknown>");
}
/// <summary>Starts the alarm feed by opening the stream and processing messages in a background task.</summary>
public void Start()
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -250,6 +260,7 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
_ => GalaxyAlarmTransitionKind.Unspecified,
};
/// <summary>Releases the alarm feed resources and stops the background stream task.</summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
@@ -28,6 +28,10 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
private readonly ConcurrentDictionary<string, int> _itemHandles =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Initializes a new Galaxy data writer.</summary>
/// <param name="session">The MXAccess gateway session.</param>
/// <param name="writeUserId">The user ID for write operations.</param>
/// <param name="logger">Optional logger for tracing.</param>
public GatewayGalaxyDataWriter(GalaxyMxSession session, int writeUserId, ILogger? logger = null)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
@@ -35,6 +39,11 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Writes values to Galaxy tags through the gateway.</summary>
/// <param name="writes">The write requests.</param>
/// <param name="securityResolver">Function to resolve security classification per tag.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The write results per request.</returns>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> securityResolver,
@@ -21,11 +21,18 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
private readonly Lock _intervalLock = new();
private int _lastAppliedIntervalMs = -1; // -1 = never applied; 0 = explicit "use gw default"
/// <summary>Initializes a new instance of GatewayGalaxySubscriber.</summary>
/// <param name="session">The Galaxy MX session to use for subscription operations.</param>
public GatewayGalaxySubscriber(GalaxyMxSession session)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
}
/// <summary>Subscribes to a bulk list of Galaxy references with optional buffered update interval.</summary>
/// <param name="fullReferences">The full Galaxy tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns a list of subscribe results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -93,6 +100,10 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
}
}
/// <summary>Unsubscribes from a bulk list of item handles.</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the unsubscribe operation.</returns>
public async Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
if (itemHandles.Count == 0) return;
@@ -106,6 +117,9 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
.ConfigureAwait(false);
}
/// <summary>Streams Galaxy MX events asynchronously.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of MX events.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
{
var session = _session.Session
@@ -22,6 +22,9 @@ public interface IGalaxyDataReader
/// Implementations MUST return the same length as the input — partial-tag
/// failures are encoded as Bad-quality snapshots, not omitted.
/// </summary>
/// <param name="fullReferences">The list of fully-qualified tag references to read.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <returns>A list of data value snapshots, one per input reference in the same order.</returns>
Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken);
}
@@ -17,10 +17,15 @@ public interface IGalaxySubscriber
/// negative — the caller treats those as per-tag failures rather than a whole-call
/// failure.
/// </summary>
/// <param name="fullReferences">The list of tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken);
/// <summary>Unsubscribe a batch of item handles obtained from <see cref="SubscribeBulkAsync"/>.</summary>
/// <param name="itemHandles">The item handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken);
/// <summary>
@@ -28,5 +33,6 @@ public interface IGalaxySubscriber
/// <see cref="MxEvent"/> carries the gw item handle the caller correlates against
/// its <see cref="SubscriptionRegistry"/>.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the stream.</param>
IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken);
}
@@ -36,6 +36,7 @@ internal static class MxAccessSeverityMapper
/// Translate a raw MXAccess severity into the four-bucket
/// <see cref="AlarmSeverity"/> + OPC UA Part 9 numeric severity tuple.
/// </summary>
/// <param name="rawMxAccessSeverity">The raw MXAccess severity value (0-999 range, clamped if out of range).</param>
public static (AlarmSeverity Bucket, int OpcUaSeverity) Map(int rawMxAccessSeverity)
{
if (rawMxAccessSeverity < 250)
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// </summary>
internal static class MxValueDecoder
{
/// <summary>Decodes a gateway MxValue into a boxed CLR object.</summary>
/// <param name="value">The MxValue to decode, or null.</param>
public static object? Decode(MxValue? value)
{
if (value is null) return null;
@@ -13,6 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// </summary>
internal static class MxValueEncoder
{
/// <summary>Encodes a CLR value as an MxValue for transmission to the gateway.</summary>
/// <param name="value">The value to encode, or null for a null MxValue.</param>
/// <returns>An MxValue instance representing the encoded value.</returns>
/// <exception cref="ArgumentException">Thrown if the value type is not supported.</exception>
public static MxValue Encode(object? value)
{
if (value is null) return new MxValue { IsNull = true };
@@ -66,6 +66,14 @@ public sealed class ReconnectSupervisor : IDisposable
/// <summary>Fires after every state transition.</summary>
public event EventHandler<StateTransition>? StateChanged;
/// <summary>
/// Initializes a new ReconnectSupervisor with the specified recovery handlers and options.
/// </summary>
/// <param name="reopen">Callback to reopen the transport connection.</param>
/// <param name="replay">Callback to replay subscriptions and state after reopening.</param>
/// <param name="options">Optional reconnection behavior configuration.</param>
/// <param name="logger">Optional logger instance; defaults to null logger if not provided.</param>
/// <param name="backoffDelay">Optional custom backoff delay calculator; uses default if not provided.</param>
public ReconnectSupervisor(
Func<CancellationToken, Task> reopen,
Func<CancellationToken, Task> replay,
@@ -92,11 +100,13 @@ public sealed class ReconnectSupervisor : IDisposable
get { lock (_stateLock) return _state != State.Healthy; }
}
/// <summary>Gets the message of the last reported transport error, if any.</summary>
public string? LastError
{
get { lock (_stateLock) return _lastError; }
}
/// <summary>Gets the UTC timestamp of the most recent state transition.</summary>
public DateTime? LastTransitionUtc
{
get { lock (_stateLock) return _lastTransitionUtc; }
@@ -108,6 +118,7 @@ public sealed class ReconnectSupervisor : IDisposable
/// first call spawns a background task that drives reopen → replay until it
/// succeeds or <see cref="Dispose"/> cancels it.
/// </summary>
/// <param name="cause">The exception that triggered the failure notification.</param>
public void ReportTransportFailure(Exception cause)
{
ArgumentNullException.ThrowIfNull(cause);
@@ -135,6 +146,7 @@ public sealed class ReconnectSupervisor : IDisposable
/// is cancelled. Returns immediately when already Healthy. Useful for tests
/// and for orchestration that wants to gate calls on recovery completing.
/// </summary>
/// <param name="cancellationToken">Token to cancel the wait operation.</param>
public async Task WaitForHealthyAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested && IsDegraded)
@@ -227,6 +239,7 @@ public sealed class ReconnectSupervisor : IDisposable
}
}
/// <summary>Disposes the supervisor and cancels any in-flight recovery loop.</summary>
public void Dispose()
{
if (_disposed) return;
@@ -263,6 +276,9 @@ public sealed record ReconnectOptions(
TimeSpan? InitialBackoffOverride = null,
TimeSpan? MaxBackoffOverride = null)
{
/// <summary>Gets the initial backoff delay for reconnection attempts.</summary>
public TimeSpan InitialBackoff => InitialBackoffOverride ?? TimeSpan.FromMilliseconds(500);
/// <summary>Gets the maximum backoff delay for reconnection attempts.</summary>
public TimeSpan MaxBackoff => MaxBackoffOverride ?? TimeSpan.FromSeconds(30);
}
@@ -49,6 +49,9 @@ internal static class StatusCodeMap
/// which is what Wonderware Historian + MXAccess surface as <c>OPCITEMSTATE.qLong</c>'s
/// low byte) to the OPC UA StatusCode uint.
/// </summary>
/// <param name="q">The OPC DA quality byte to convert.</param>
/// <param name="logger">Optional logger for diagnostics on unknown quality bytes.</param>
/// <returns>The OPC UA status code.</returns>
public static uint FromQualityByte(byte q, ILogger? logger = null) => q switch
{
// Good family — top two bits 11b (192-255).
@@ -83,6 +86,9 @@ internal static class StatusCodeMap
/// the authoritative indicator. On failure, the detail byte (OPC DA quality substatus)
/// drives the specific code, with a transport-error fallback for pre-MXAccess failures.
/// </summary>
/// <param name="status">The MX status proxy to convert, or null for Good status.</param>
/// <param name="logger">Optional logger for diagnostics on unknown status codes.</param>
/// <returns>The OPC UA status code.</returns>
public static uint FromMxStatus(MxStatusProxy? status, ILogger? logger = null)
{
if (status is null) return Good;
@@ -112,6 +118,8 @@ internal static class StatusCodeMap
/// the round-trip in one place means a future change to the OPC UA bit layout cannot
/// silently desync the probe-health decode.
/// </summary>
/// <param name="statusCode">The OPC UA status code to convert.</param>
/// <returns>The OPC DA quality category byte.</returns>
public static byte ToQualityCategoryByte(uint statusCode) =>
(byte)(((statusCode >> 30) & 0x3u) switch
{
@@ -26,7 +26,9 @@ internal sealed class SubscriptionRegistry
private readonly ConcurrentDictionary<int, ImmutableHashSet<long>> _subscribersByItemHandle = new();
private long _nextSubscriptionId;
/// <summary>Gets the number of tracked subscriptions.</summary>
public int TrackedSubscriptionCount => _bySubscriptionId.Count;
/// <summary>Gets the number of tracked item handles.</summary>
public int TrackedItemHandleCount => _subscribersByItemHandle.Count;
/// <summary>Allocate a fresh subscription id. Monotonic; unique per registry lifetime.</summary>
@@ -37,6 +39,8 @@ internal sealed class SubscriptionRegistry
/// Failed tags (item handle = 0 or negative) are stored anyway so unsubscribe can
/// emit per-tag UnsubscribeBulk for the ones that did succeed.
/// </summary>
/// <param name="subscriptionId">The subscription identifier.</param>
/// <param name="bindings">The tag bindings for the subscription.</param>
public void Register(long subscriptionId, IReadOnlyList<TagBinding> bindings)
{
var entry = new SubscriptionEntry(subscriptionId, bindings);
@@ -55,6 +59,8 @@ internal sealed class SubscriptionRegistry
/// Remove a subscription. Returns the bindings the caller should pass to
/// <c>UnsubscribeBulkAsync</c>; null when the id was never registered.
/// </summary>
/// <param name="subscriptionId">The subscription identifier.</param>
/// <returns>The bindings for the subscription, or null if not found.</returns>
public IReadOnlyList<TagBinding>? Remove(long subscriptionId)
{
if (!_bySubscriptionId.TryRemove(subscriptionId, out var entry)) return null;
@@ -83,6 +89,8 @@ internal sealed class SubscriptionRegistry
/// scan of the binding list. At 50k tags / 1Hz this turns each dispatch from a
/// 50k-element scan into a single dictionary lookup.
/// </remarks>
/// <param name="itemHandle">The gateway item handle.</param>
/// <returns>A list of subscription and reference pairs for the item handle.</returns>
public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int itemHandle)
{
if (!_subscribersByItemHandle.TryGetValue(itemHandle, out var bag)) return [];
@@ -117,6 +125,8 @@ internal sealed class SubscriptionRegistry
/// handles dispatch and the now-dead pre-reconnect handles are dropped. No-op when the
/// subscription id is unknown (it was unsubscribed during the reconnect window).
/// </summary>
/// <param name="subscriptionId">The subscription identifier.</param>
/// <param name="newBindings">The new tag bindings after reconnection.</param>
public void Rebind(long subscriptionId, IReadOnlyList<TagBinding> newBindings)
{
if (!_bySubscriptionId.TryGetValue(subscriptionId, out var oldEntry)) return;
@@ -151,12 +161,19 @@ internal sealed class SubscriptionRegistry
/// (Driver.Galaxy-012). Failed bindings (item handle ≤ 0) are excluded from the
/// index because the EventPump only dispatches for positive handles.
/// </summary>
/// <summary>Per-subscription bookkeeping entry.</summary>
private sealed class SubscriptionEntry
{
/// <summary>Gets the subscription identifier.</summary>
public long SubscriptionId { get; }
/// <summary>Gets the tag bindings for the subscription.</summary>
public IReadOnlyList<TagBinding> Bindings { get; }
/// <summary>Gets the index of full references by item handle.</summary>
public IReadOnlyDictionary<int, string> FullRefByItemHandle { get; }
/// <summary>Initializes a new subscription entry.</summary>
/// <param name="subscriptionId">The subscription identifier.</param>
/// <param name="bindings">The tag bindings for the subscription.</param>
public SubscriptionEntry(long subscriptionId, IReadOnlyList<TagBinding> bindings)
{
SubscriptionId = subscriptionId;
@@ -10,6 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// </summary>
internal sealed class TracedGalaxyDataWriter(IGalaxyDataWriter inner, string clientName) : IGalaxyDataWriter
{
/// <summary>Writes data to Galaxy while recording telemetry span.</summary>
/// <param name="writes">The list of write requests to process.</param>
/// <param name="securityResolver">Function to resolve security classification for tag references.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> securityResolver,
@@ -10,6 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// </summary>
internal sealed class TracedGalaxySubscriber(IGalaxySubscriber inner, string clientName) : IGalaxySubscriber
{
/// <summary>Subscribes to multiple Galaxy tags in bulk with tracing.</summary>
/// <param name="fullReferences">The full tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -31,6 +35,9 @@ internal sealed class TracedGalaxySubscriber(IGalaxySubscriber inner, string cli
}
}
/// <summary>Unsubscribes from multiple Galaxy tags in bulk with tracing.</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
using var activity = GalaxyTelemetry.ActivitySource.StartActivity("galaxy.unsubscribe_bulk");
@@ -52,6 +59,7 @@ internal sealed class TracedGalaxySubscriber(IGalaxySubscriber inner, string cli
/// spans would dominate the trace volume at 50k tags / 1Hz; ops gets per-event
/// visibility through <see cref="EventPump"/>'s metrics in PR 6.2 instead.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{