docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
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>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -67,11 +67,13 @@ public abstract class CommandBase : ICommand
/// Executes the command-specific workflow against the configured OPC UA endpoint.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public abstract ValueTask ExecuteAsync(IConsole console);
/// <summary>
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
/// </summary>
/// <returns>The connection settings built from the current command options.</returns>
protected ConnectionSettings CreateConnectionSettings()
{
var securityMode = SecurityModeMapper.FromString(Security);
@@ -97,6 +99,7 @@ public abstract class CommandBase : ICommand
/// and returns both the service and the connection info.
/// </summary>
/// <param name="ct">The cancellation token that aborts connection setup for the command.</param>
/// <returns>The connected client service along with its connection info.</returns>
protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(
CancellationToken ct)
{
@@ -36,10 +36,7 @@ public class AcknowledgeCommand : CommandBase
[CommandOption("comment", 'c', Description = "Operator comment for the acknowledgment")]
public string Comment { get; init; } = string.Empty;
/// <summary>
/// Connects to the server and acknowledges the specified alarm condition.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -38,10 +38,6 @@ public class AlarmsCommand : CommandBase
[CommandOption("refresh", Description = "Request a ConditionRefresh after subscribing")]
public bool Refresh { get; init; }
/// <summary>
/// Connects to the server, subscribes to alarm events, and streams operator-facing alarm state changes to the console.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -36,10 +36,7 @@ public class ConfirmCommand : CommandBase
[CommandOption("comment", 'c', Description = "Operator comment for the confirmation")]
public string Comment { get; init; } = string.Empty;
/// <summary>
/// Connects to the server and confirms the specified acknowledged alarm condition.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -24,10 +24,7 @@ public class DisableCommand : CommandBase
[CommandOption("node", 'n', Description = "Condition node ID of the alarm to disable", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Connects to the server and disables the specified alarm condition.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -24,10 +24,7 @@ public class EnableCommand : CommandBase
[CommandOption("node", 'n', Description = "Condition node ID of the alarm to enable", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Connects to the server and enables the specified alarm condition.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -15,7 +15,6 @@ public class RedundancyCommand : CommandBase
{
}
/// <summary>Connects to the server and prints redundancy mode, service level, and partner-server identity data.</summary>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -38,10 +38,7 @@ public class ShelveCommand : CommandBase
[CommandOption("duration", 'd', Description = "Shelving duration in seconds (must be > 0; in seconds, converted to milliseconds for the OPC UA call; required for --kind Timed)")]
public double DurationSeconds { get; init; }
/// <summary>
/// Connects to the server and shelves or unshelves the specified alarm condition.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
@@ -31,10 +31,6 @@ public class WriteCommand : CommandBase
[CommandOption("value", 'v', Description = "Value to write", IsRequired = true)]
public string Value { get; init; } = default!;
/// <summary>
/// Connects to the server, converts the supplied value to the node's current data type, and issues the write.
/// </summary>
/// <param name="console">The CLI console used for output and cancellation handling.</param>
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -12,9 +12,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
{
private static readonly ILogger Logger = Log.ForContext<DefaultApplicationConfigurationFactory>();
/// <summary>Creates an OPC UA application configuration from the provided connection settings.</summary>
/// <param name="settings">The connection settings to use.</param>
/// <param name="ct">Token to cancel the operation.</param>
/// <inheritdoc />
public async Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct)
{
// Resolve the canonical PKI path lazily on first use so constructing a
@@ -11,10 +11,7 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
{
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
/// <summary>Selects an OPC UA endpoint matching the requested security mode.</summary>
/// <param name="config">The application configuration.</param>
/// <param name="endpointUrl">The endpoint URL to query.</param>
/// <param name="requestedMode">The requested message security mode.</param>
/// <inheritdoc />
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode)
{
@@ -53,6 +50,7 @@ internal static class EndpointSelector
/// Thrown when no endpoint matches <paramref name="requestedMode"/>; the message lists the
/// security mode + policy combinations the server returned so operators can diagnose mismatches.
/// </exception>
/// <returns>The best-matching endpoint, with its URL host rewritten to match <paramref name="endpointUrl"/> if needed.</returns>
public static EndpointDescription SelectBest(
IEnumerable<EndpointDescription> allEndpoints,
string endpointUrl,
@@ -109,7 +109,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
byte[] continuationPoint, CancellationToken ct)
{
// Pass the caller's token so a cancelled browse does not block on the continuation
// round-trip (Client.Shared-015).
// round-trip.
var (_, nextCp, nextRefs) = await _session.BrowseNextAsync(null, false, continuationPoint, ct);
return (nextCp, nextRefs ?? []);
}
@@ -11,14 +11,7 @@ internal sealed class DefaultSessionFactory : ISessionFactory
{
private static readonly ILogger Logger = Log.ForContext<DefaultSessionFactory>();
/// <summary>Creates a new OPC UA session.</summary>
/// <param name="config">The OPC UA application configuration.</param>
/// <param name="endpoint">The endpoint description to connect to.</param>
/// <param name="sessionName">The name for the session.</param>
/// <param name="sessionTimeoutMs">The session timeout in milliseconds.</param>
/// <param name="identity">The user identity for the session.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>An adapter wrapping the created session.</returns>
/// <inheritdoc />
public async Task<ISessionAdapter> CreateSessionAsync(
ApplicationConfiguration config,
EndpointDescription endpoint,
@@ -105,7 +105,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
{
try
{
// Forward the caller's token so the delete can be cancelled (Client.Shared-014).
// Forward the caller's token so the delete can be cancelled.
await _subscription.DeleteAsync(true, ct);
}
catch (Exception ex)
@@ -13,5 +13,6 @@ internal interface IApplicationConfigurationFactory
/// </summary>
/// <param name="settings">The connection settings to configure.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The validated <see cref="ApplicationConfiguration"/>.</returns>
Task<ApplicationConfiguration> CreateAsync(ConnectionSettings settings, CancellationToken ct = default);
}
@@ -14,6 +14,7 @@ internal interface IEndpointDiscovery
/// <param name="config">The OPC UA application configuration.</param>
/// <param name="endpointUrl">The endpoint URL to discover.</param>
/// <param name="requestedMode">The requested message security mode.</param>
/// <returns>The best-matching endpoint description, with its URL hostname rewritten to match <paramref name="endpointUrl"/> when they differ.</returns>
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode);
}
@@ -58,6 +58,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="nodeId">The node whose current runtime value should be read.</param>
/// <param name="ct">The cancellation token that aborts the server read if the client cancels the request.</param>
/// <returns>The current data value read from the server.</returns>
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -66,6 +67,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="nodeId">The node whose value should be updated.</param>
/// <param name="value">The typed OPC UA data value to write to the server.</param>
/// <param name="ct">The cancellation token that aborts the write if the client cancels the request.</param>
/// <returns>The status code returned by the server for the write operation.</returns>
Task<StatusCode> WriteValueAsync(NodeId nodeId, DataValue value, CancellationToken ct = default);
/// <summary>
@@ -75,6 +77,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="nodeId">The starting node for the hierarchical browse.</param>
/// <param name="nodeClassMask">The node classes that should be returned to the caller.</param>
/// <param name="ct">The cancellation token that aborts the browse request.</param>
/// <returns>A continuation point (or <c>null</c> when complete) and the references found.</returns>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseAsync(
NodeId nodeId, uint nodeClassMask = 0, CancellationToken ct = default);
@@ -83,6 +86,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="continuationPoint">The continuation token returned by a prior browse result page.</param>
/// <param name="ct">The cancellation token that aborts the browse-next request.</param>
/// <returns>The next continuation point (or <c>null</c> when exhausted) and the additional references.</returns>
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
byte[] continuationPoint, CancellationToken ct = default);
@@ -91,6 +95,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
/// <param name="ct">The cancellation token that aborts the child lookup.</param>
/// <returns><c>true</c> when the node has at least one forward hierarchical child reference; otherwise <c>false</c>.</returns>
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -101,6 +106,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="endTime">The inclusive end of the requested history window.</param>
/// <param name="maxValues">The maximum number of raw samples to return to the client.</param>
/// <param name="ct">The cancellation token that aborts the history read.</param>
/// <returns>The raw historical data values for the requested window.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues, CancellationToken ct = default);
@@ -113,6 +119,7 @@ internal interface ISessionAdapter : IDisposable
/// <param name="aggregateId">The OPC UA aggregate function to evaluate over the history window.</param>
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
/// <param name="ct">The cancellation token that aborts the aggregate history read.</param>
/// <returns>The processed/aggregate data values for the requested window.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
NodeId aggregateId, double intervalMs, CancellationToken ct = default);
@@ -121,6 +128,7 @@ internal interface ISessionAdapter : IDisposable
/// </summary>
/// <param name="publishingIntervalMs">The requested publishing interval for monitored items on the new subscription.</param>
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
/// <returns>The newly created subscription adapter.</returns>
Task<ISubscriptionAdapter> CreateSubscriptionAsync(int publishingIntervalMs, CancellationToken ct = default);
/// <summary>
@@ -130,11 +138,13 @@ internal interface ISessionAdapter : IDisposable
/// <param name="methodId">The method node to invoke.</param>
/// <param name="inputArguments">The ordered input arguments supplied to the server method call.</param>
/// <param name="ct">The cancellation token that aborts the method invocation.</param>
/// <returns>The output arguments returned by the method call.</returns>
Task<IList<object>?> CallMethodAsync(NodeId objectId, NodeId methodId, object[] inputArguments, CancellationToken ct = default);
/// <summary>
/// Closes the underlying session gracefully before the adapter is disposed or replaced during failover.
/// </summary>
/// <param name="ct">The cancellation token that aborts the close request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task CloseAsync(CancellationToken ct = default);
}
@@ -28,6 +28,7 @@ internal interface ISubscriptionAdapter : IDisposable
/// </summary>
/// <param name="clientHandle">The client handle returned when the monitored item was created.</param>
/// <param name="ct">The cancellation token that aborts the monitored-item removal.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RemoveMonitoredItemAsync(uint clientHandle, CancellationToken ct = default);
/// <summary>
@@ -46,11 +47,13 @@ internal interface ISubscriptionAdapter : IDisposable
/// Requests a condition refresh for this subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ConditionRefreshAsync(CancellationToken ct = default);
/// <summary>
/// Removes all monitored items and deletes the subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts subscription deletion.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DeleteAsync(CancellationToken ct = default);
}
@@ -2,8 +2,8 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
/// <summary>
/// Resolves the canonical under-LocalAppData folder for the shared OPC UA client's PKI
/// store + persisted settings. Renamed from <c>LmxOpcUaClient</c> to <c>OtOpcUaClient</c>
/// in task #208; a one-shot migration shim moves a pre-rename folder in place on first
/// store + persisted settings. Renamed from <c>LmxOpcUaClient</c> to <c>OtOpcUaClient</c>;
/// a one-shot migration shim moves a pre-rename folder in place on first
/// resolution so existing developer boxes keep their trusted server certs + saved
/// connection settings on upgrade.
/// </summary>
@@ -14,10 +14,10 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
/// </remarks>
public static class ClientStoragePaths
{
/// <summary>Canonical client folder name. Post-#208.</summary>
/// <summary>Canonical client folder name.</summary>
public const string CanonicalFolderName = "OtOpcUaClient";
/// <summary>Pre-#208 folder name. Used only by the migration shim.</summary>
/// <summary>Legacy folder name, used only by the migration shim.</summary>
public const string LegacyFolderName = "LmxOpcUaClient";
private static readonly Lock _migrationLock = new();
@@ -28,6 +28,7 @@ public static class ClientStoragePaths
/// one-shot legacy-folder migration before returning so callers that depend on this
/// path (PKI store, settings file) find their existing state at the canonical name.
/// </summary>
/// <returns>The absolute path to the client's canonical top-level folder.</returns>
public static string GetRoot()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
@@ -37,6 +38,7 @@ public static class ClientStoragePaths
}
/// <summary>Subfolder for the application's PKI store — used by both CLI + UI.</summary>
/// <returns>The absolute path to the client's PKI store folder.</returns>
public static string GetPkiPath() => Path.Combine(GetRoot(), "pki");
/// <summary>
@@ -45,6 +47,7 @@ public static class ClientStoragePaths
/// folder existed + was moved to canonical, false when no migration was needed or
/// canonical was already present.
/// </summary>
/// <returns><c>true</c> when a legacy folder existed and was moved to canonical; otherwise <c>false</c>.</returns>
public static bool TryRunLegacyMigration()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
@@ -24,12 +24,14 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="settings">The endpoint, security, and authentication settings used to establish the session.</param>
/// <param name="ct">The cancellation token that aborts the connect workflow.</param>
/// <returns>The connection metadata for the newly established session.</returns>
Task<ConnectionInfo> ConnectAsync(ConnectionSettings settings, CancellationToken ct = default);
/// <summary>
/// Disconnects from the active OPC UA endpoint and tears down subscriptions owned by the client.
/// </summary>
/// <param name="ct">The cancellation token that aborts disconnect cleanup.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>
@@ -37,6 +39,7 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="nodeId">The node whose value should be retrieved.</param>
/// <param name="ct">The cancellation token that aborts the read request.</param>
/// <returns>The current data value of the node.</returns>
Task<DataValue> ReadValueAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -45,6 +48,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="nodeId">The node whose value should be updated.</param>
/// <param name="value">The raw value supplied by the CLI or UI workflow.</param>
/// <param name="ct">The cancellation token that aborts the write request.</param>
/// <returns>The status code returned by the server for the write.</returns>
Task<StatusCode> WriteValueAsync(NodeId nodeId, object value, CancellationToken ct = default);
/// <summary>
@@ -52,6 +56,7 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="parentNodeId">The node to browse, or <see cref="ObjectIds.ObjectsFolder"/> when omitted.</param>
/// <param name="ct">The cancellation token that aborts the browse request.</param>
/// <returns>The child nodes discovered under <paramref name="parentNodeId"/>.</returns>
Task<IReadOnlyList<BrowseResult>> BrowseAsync(NodeId? parentNodeId = null, CancellationToken ct = default);
/// <summary>
@@ -60,6 +65,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="nodeId">The node whose value changes should be monitored.</param>
/// <param name="intervalMs">The monitored-item sampling and publishing interval in milliseconds.</param>
/// <param name="ct">The cancellation token that aborts subscription creation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SubscribeAsync(NodeId nodeId, int intervalMs = 1000, CancellationToken ct = default);
/// <summary>
@@ -67,6 +73,7 @@ public interface IOpcUaClientService : IDisposable
/// </summary>
/// <param name="nodeId">The node whose live-data subscription should be removed.</param>
/// <param name="ct">The cancellation token that aborts the unsubscribe request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UnsubscribeAsync(NodeId nodeId, CancellationToken ct = default);
/// <summary>
@@ -75,18 +82,21 @@ public interface IOpcUaClientService : IDisposable
/// <param name="sourceNodeId">The event source to monitor, or the server object when omitted.</param>
/// <param name="intervalMs">The publishing interval in milliseconds for the alarm subscription.</param>
/// <param name="ct">The cancellation token that aborts alarm subscription creation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SubscribeAlarmsAsync(NodeId? sourceNodeId = null, int intervalMs = 1000, CancellationToken ct = default);
/// <summary>
/// Removes the active alarm subscription.
/// </summary>
/// <param name="ct">The cancellation token that aborts alarm subscription cleanup.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UnsubscribeAlarmsAsync(CancellationToken ct = default);
/// <summary>
/// Requests retained alarm conditions again so a client can repopulate its alarm list after reconnecting.
/// </summary>
/// <param name="ct">The cancellation token that aborts the condition refresh request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RequestConditionRefreshAsync(CancellationToken ct = default);
/// <summary>
@@ -169,6 +179,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="endTime">The inclusive end of the requested history range.</param>
/// <param name="maxValues">The maximum number of raw values to return.</param>
/// <param name="ct">The cancellation token that aborts the history read.</param>
/// <returns>The raw historical samples in the requested range, up to <paramref name="maxValues"/>.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default);
@@ -181,6 +192,7 @@ public interface IOpcUaClientService : IDisposable
/// <param name="aggregate">The aggregate function the operator selected for processed history.</param>
/// <param name="intervalMs">The processing interval, in milliseconds, for each aggregate bucket.</param>
/// <param name="ct">The cancellation token that aborts the processed history request.</param>
/// <returns>The aggregated historical values for each processing interval in the requested range.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
@@ -188,6 +200,7 @@ public interface IOpcUaClientService : IDisposable
/// Reads redundancy status data such as redundancy mode, service level, and partner endpoint URIs.
/// </summary>
/// <param name="ct">The cancellation token that aborts redundancy inspection.</param>
/// <returns>The current redundancy status of the connected server.</returns>
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
/// <summary>
@@ -24,7 +24,6 @@ public sealed class OpcUaClientService : IOpcUaClientService
// Serialises SubscribeAlarmsAsync / UnsubscribeAlarmsAsync so concurrent callers
// cannot both pass the _alarmSubscription == null check and create duplicate event
// subscriptions. Capacity 1 = at most one waiter; async-friendly (no thread-block).
// (Client.Shared-013)
private readonly SemaphoreSlim _alarmSubscribeSemaphore = new(1, 1);
// Track active data subscriptions for replay after failover
@@ -137,7 +136,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
var endpointUrl = _session?.EndpointUrl ?? _settings?.EndpointUrl ?? string.Empty;
// Snapshot the subscription adapter references under the lock so a concurrent
// RunFailoverAsync that nulls _dataSubscription / _alarmSubscription (Client.Shared-012)
// RunFailoverAsync that nulls _dataSubscription / _alarmSubscription
// cannot invalidate the references between the null-check and the DeleteAsync call.
ISubscriptionAdapter? dataSubscription;
ISubscriptionAdapter? alarmSubscription;
@@ -303,7 +302,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// Serialise check-and-create under the semaphore (Client.Shared-013): concurrent
// Serialise check-and-create under the semaphore: concurrent
// callers cannot both pass the null-check and create duplicate alarm subscriptions.
// The semaphore is async-friendly; the subscription I/O runs inside the critical section.
await _alarmSubscribeSemaphore.WaitAsync(ct);
@@ -5,8 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
/// </summary>
public sealed class OpcUaClientServiceFactory : IOpcUaClientServiceFactory
{
/// <summary>Creates a new OPC UA client service instance with production adapters.</summary>
/// <returns>A new OpcUaClientService instance.</returns>
/// <inheritdoc />
public IOpcUaClientService Create()
{
return new OpcUaClientService();
@@ -7,8 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class AvaloniaUiDispatcher : IUiDispatcher
{
/// <summary>Posts an action to the Avalonia UI thread for execution.</summary>
/// <param name="action">The action to execute on the UI thread.</param>
/// <inheritdoc />
public void Post(Action action)
{
Dispatcher.UIThread.Post(action);
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
public interface ISettingsService
{
/// <summary>Loads user settings from persistent storage.</summary>
/// <returns>The loaded user settings.</returns>
UserSettings Load();
/// <summary>Saves user settings to persistent storage.</summary>
/// <param name="settings">The settings to save.</param>
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class JsonSettingsService : ISettingsService
{
// ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208
// ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-rename
// developer boxes pick up their existing settings.json on first launch post-rename.
private static readonly string SettingsDir = ClientStoragePaths.GetRoot();
@@ -19,8 +19,8 @@ public sealed class JsonSettingsService : ISettingsService
WriteIndented = true
};
/// <summary>Loads user settings from the settings file.</summary>
/// <returns>The loaded user settings, or a new default instance if load fails.</returns>
/// <inheritdoc />
// Returns a new default instance when the settings file is missing or fails to load/parse.
public UserSettings Load()
{
try
@@ -37,8 +37,7 @@ public sealed class JsonSettingsService : ISettingsService
}
}
/// <summary>Saves user settings to the settings file.</summary>
/// <param name="settings">The user settings to save.</param>
/// <inheritdoc />
public void Save(UserSettings settings)
{
try
@@ -6,8 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary>
public sealed class SynchronousUiDispatcher : IUiDispatcher
{
/// <summary>Executes the action synchronously on the calling thread.</summary>
/// <param name="action">The action to execute.</param>
/// <inheritdoc />
public void Post(Action action)
{
action();
@@ -256,6 +256,7 @@ public partial class AlarmsViewModel : ObservableObject
/// <summary>
/// Returns the monitored node ID for persistence, or null if not subscribed.
/// </summary>
/// <returns>The monitored node ID, or null if not subscribed.</returns>
public string? GetAlarmSourceNodeId()
{
return IsSubscribed ? MonitoredNodeIdText : null;
@@ -30,6 +30,7 @@ public class BrowseTreeViewModel : ObservableObject
/// <summary>
/// Loads root nodes by browsing with a null parent.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task LoadRootsAsync()
{
var results = await _service.BrowseAsync();
@@ -143,6 +143,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// </summary>
/// <param name="nodeIdStr">The node ID to subscribe to from the browse tree or persisted settings.</param>
/// <param name="intervalMs">The monitored-item interval, in milliseconds, for the subscription.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000)
{
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
@@ -176,6 +177,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// <param name="nodeIdStr">The root node whose variables should be subscribed recursively.</param>
/// <param name="nodeClass">The node class of the starting node so variables can be subscribed immediately.</param>
/// <param name="intervalMs">The monitored-item interval, in milliseconds, used for created subscriptions.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs = 1000)
{
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
@@ -211,6 +213,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// <summary>
/// Returns the node IDs of all active subscriptions for persistence.
/// </summary>
/// <returns>The node IDs of all currently active subscriptions.</returns>
public List<string> GetSubscribedNodeIds()
{
return ActiveSubscriptions.Select(s => s.NodeId).ToList();
@@ -220,6 +223,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// Restores subscriptions from a saved list of node IDs.
/// </summary>
/// <param name="nodeIds">The node IDs persisted from a prior UI session.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
{
foreach (var nodeId in nodeIds)
@@ -232,6 +236,7 @@ public partial class SubscriptionsViewModel : ObservableObject
/// </summary>
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
/// <param name="rawValue">The raw text value entered by the operator.</param>
/// <returns>A tuple indicating whether the write succeeded and a human-readable status message.</returns>
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
{
try
@@ -20,6 +20,8 @@ public partial class ShelveAlarmWindow : Window
}
/// <summary>Creates the shelve dialog for an alarm.</summary>
/// <param name="alarmsVm">The view model used to issue the shelve/unshelve command.</param>
/// <param name="alarm">The alarm event being shelved.</param>
public ShelveAlarmWindow(AlarmsViewModel alarmsVm, AlarmEventViewModel alarm)
{
InitializeComponent();
@@ -43,20 +43,16 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
}
/// <summary>Gets the local cluster node identifier.</summary>
/// <inheritdoc />
public CommonsNodeId LocalNode => _localNode;
/// <summary>Gets the set of roles assigned to the local node.</summary>
/// <inheritdoc />
public IReadOnlySet<string> LocalRoles => _localRoles;
/// <summary>Checks if the local node has a specific role.</summary>
/// <param name="role">The role name to check.</param>
/// <returns>True if the local node has the specified role; otherwise false.</returns>
/// <inheritdoc />
public bool HasRole(string role) => _localRoles.Contains(role);
/// <summary>Gets all cluster members that have a specific role.</summary>
/// <param name="role">The role name.</param>
/// <returns>A read-only list of node IDs with the specified role.</returns>
/// <inheritdoc />
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
{
lock (_lock)
@@ -68,9 +64,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
}
}
/// <summary>Gets the current leader node for a specific role.</summary>
/// <param name="role">The role name.</param>
/// <returns>The node ID of the current role leader, or null if no leader is elected.</returns>
/// <inheritdoc />
public CommonsNodeId? RoleLeader(string role)
{
lock (_lock)
@@ -9,6 +9,7 @@ public static class RoleParser
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
/// <param name="raw">The raw role string to parse.</param>
/// <returns>The distinct, lower-cased, validated role names.</returns>
public static string[] Parse(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
@@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="configuration">The application configuration containing cluster options.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<AkkaClusterOptions>()
@@ -47,6 +48,7 @@ public static class ServiceCollectionExtensions
/// </summary>
/// <param name="builder">The Akka configuration builder to configure.</param>
/// <param name="serviceProvider">The service provider for resolving cluster options.</param>
/// <returns>The same builder, for chaining.</returns>
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
this AkkaConfigurationBuilder builder,
IServiceProvider serviceProvider)
@@ -16,14 +16,22 @@ public interface IBrowseSession : IAsyncDisposable
DateTime LastUsedUtc { get; }
/// <summary>Returns the top-level browse nodes.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The top-level browse nodes.</returns>
Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken);
/// <summary>Returns the direct children of the node identified by
/// <paramref name="nodeId"/>.</summary>
/// <param name="nodeId">The node whose direct children are returned.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The direct children of the node.</returns>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken);
/// <summary>Returns the attributes of the node identified by <paramref name="nodeId"/>.
/// Empty for drivers whose tree is uniform (OPC UA Client). Galaxy uses this to populate
/// the attribute side-panel after the user selects an object.</summary>
/// <param name="nodeId">The node whose attributes are returned.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
@@ -15,5 +15,6 @@ public interface IDriverBrowser
/// <param name="configJson">Driver options serialized as JSON; same shape the runtime
/// driver would consume.</param>
/// <param name="cancellationToken">Cancellation for the connect phase only.</param>
/// <returns>The opened browse session.</returns>
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
}
@@ -43,11 +43,7 @@ public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
{
public static readonly NullVirtualTagEvaluator Instance = new();
private NullVirtualTagEvaluator() { }
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> for every evaluation.</summary>
/// <param name="virtualTagId">The virtual tag identifier (ignored).</param>
/// <param name="expression">The expression string (ignored).</param>
/// <param name="dependencies">The variable dependencies (ignored).</param>
/// <returns>Always returns <see cref="VirtualTagEvalResult.NoChange"/>.</returns>
/// <inheritdoc />
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.NoChange;
}
@@ -53,5 +53,6 @@ public interface IAdminOperationsClient
/// <typeparam name="T">Expected reply type.</typeparam>
/// <param name="message">The message to send.</param>
/// <param name="ct">Cancellation token (caller-controlled timeout).</param>
/// <returns>The reply of type <typeparamref name="T"/> received from the admin singleton.</returns>
Task<T> AskAsync<T>(object message, CancellationToken ct);
}
@@ -11,5 +11,6 @@ public interface IFleetDiagnosticsClient
/// <summary>Gets diagnostics for the specified node.</summary>
/// <param name="nodeId">The node ID to retrieve diagnostics for.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The diagnostics snapshot for the requested node.</returns>
Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct);
}
@@ -69,6 +69,7 @@ public static class OtOpcUaTelemetry
/// null when no listener is attached so the call site stays cheap on undecorated builds.
/// </summary>
/// <param name="deploymentId">The deployment identifier to tag the span with.</param>
/// <returns>The started activity, or <c>null</c> when no listener is attached.</returns>
public static Activity? StartDeployApplySpan(string deploymentId)
{
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
@@ -77,6 +78,7 @@ public static class OtOpcUaTelemetry
}
/// <summary>Span wrapping a full OPC UA address-space rebuild (AddressSpace plan → apply).</summary>
/// <returns>The started activity, or <c>null</c> when no listener is attached.</returns>
public static Activity? StartAddressSpaceRebuildSpan()
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
}
@@ -22,85 +22,49 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public void SetSink(IOpcUaAddressSpaceSink? sink) =>
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <summary>Writes a value to the OPC UA address space through the inner sink.</summary>
/// <param name="nodeId">The node ID of the variable.</param>
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
/// <summary>Writes a full alarm-condition state through the inner sink.</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
/// <summary>Materialises a real Part 9 alarm-condition node through the inner sink.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID the condition parents under.</param>
/// <param name="displayName">The human-readable condition name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
/// <param name="isNative">True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.</param>
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
/// <summary>Ensures a folder exists in the address space through the inner sink.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
/// <summary>Ensures a variable exists in the address space through the inner sink.</summary>
/// <param name="variableNodeId">The node ID of the variable.</param>
/// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type of the variable.</param>
/// <param name="writable">When true the node is created read/write; otherwise read-only.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ create Historizing with the
/// HistoryRead access bit and register the historian tagname.</param>
/// <param name="isArray">When true the node is created as a 1-D array; when false (default) scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
/// <summary>Rebuilds the address space through the inner sink.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <summary>Announces a runtime NodeAdded model-change (discovered-node injection) through the inner sink.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId);
/// <summary>Forwards an in-place tag-attribute update (F10b) to the inner sink when it supports the
/// surgical capability. Returns false otherwise — before the real <c>SdkAddressSpaceSink</c> is
/// swapped in (inner is still the null sink), or any inner sink that isn't surgical — so the caller
/// (AddressSpaceApplier) falls back to a full rebuild. Without this forward the surgical optimization is
/// inert on every driver-role host, because actors inject THIS wrapper, not the inner sink. ALL six args
/// (including the FB-7 DataType/array-shape ones) MUST be forwarded — a partial forward silently drops the
/// shape update on every driver-role host.</summary>
/// <param name="variableNodeId">The node ID of the variable to update in place.</param>
/// <param name="writable">Whether the node should be read/write.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing + historian binding.</param>
/// <param name="dataType">The OPC UA built-in data type name to apply in place.</param>
/// <param name="isArray">When true the node becomes a 1-D array; when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
/// <returns>True when the inner sink applied the update; false when it lacks the capability or the node is missing.</returns>
/// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
// before the real SdkAddressSpaceSink is swapped in (inner is still the null sink), or any inner
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
// Without this forward the surgical optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
/// <summary>Forwards an in-place folder-display-name update (OpcUaServer-001 — UNS Area / Line
/// rename) to the inner sink when it supports the surgical capability. Returns false otherwise —
/// before the real <c>SdkAddressSpaceSink</c> is swapped in (inner is still the null sink), or any
/// inner sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
/// Without this forward the rename-refresh optimization is inert on every driver-role host, because
/// actors inject THIS wrapper, not the inner sink.</summary>
/// <param name="folderNodeId">The folder node id whose display name to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the inner sink applied the update; false when it lacks the capability or the folder is missing.</returns>
/// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
// before the real SdkAddressSpaceSink is swapped in (inner is still the null sink), or any inner
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink.
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
@@ -16,7 +16,6 @@ public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
public void SetInner(IServiceLevelPublisher? inner) =>
_inner = inner ?? NullServiceLevelPublisher.Instance;
/// <summary>Publishes a service level value to the inner publisher.</summary>
/// <param name="serviceLevel">The service level to publish.</param>
/// <inheritdoc />
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
}
@@ -23,7 +23,6 @@ public sealed class NullServiceLevelPublisher : IServiceLevelPublisher
/// <summary>Gets the last published service level value.</summary>
public byte LastPublished { get; private set; }
/// <summary>Records the service level value without publishing.</summary>
/// <param name="serviceLevel">The service level value (0-255).</param>
/// <inheritdoc />
public void Publish(byte serviceLevel) => LastPublished = serviceLevel;
}
@@ -3,15 +3,18 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
public readonly record struct CorrelationId(Guid Value)
{
/// <summary>Creates a new CorrelationId with a randomly generated GUID.</summary>
/// <returns>A new, randomly generated <see cref="CorrelationId"/>.</returns>
public static CorrelationId NewId() => new(Guid.NewGuid());
/// <inheritdoc />
public override string ToString() => Value.ToString("N");
/// <summary>Parses a lowercase hex string without hyphens into a CorrelationId.</summary>
/// <param name="s">The string to parse.</param>
/// <returns>The parsed <see cref="CorrelationId"/>.</returns>
public static CorrelationId Parse(string s) => new(Guid.ParseExact(s, "N"));
/// <summary>Attempts to parse a lowercase hex string without hyphens into a CorrelationId.</summary>
/// <param name="s">The string to parse, or null.</param>
/// <param name="id">The resulting CorrelationId if parsing succeeds.</param>
/// <returns><c>true</c> if <paramref name="s"/> was successfully parsed.</returns>
public static bool TryParse(string? s, out CorrelationId id)
{
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
@@ -41,6 +41,7 @@ public static class EquipmentScriptPaths
/// <summary>True when the source uses the <c>{{equip}}</c> token anywhere.</summary>
/// <param name="source">The script source to scan.</param>
/// <returns>True if <paramref name="source"/> contains the <c>{{equip}}</c> token; otherwise false.</returns>
public static bool ContainsEquipToken(string? source) =>
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
@@ -20,7 +20,7 @@ public sealed class ClusterNode
/// <summary>
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
/// Fleet-wide unique index enforces no two nodes share a value.
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
/// hostname change would break all client trust.
/// </summary>
@@ -31,7 +31,7 @@ public sealed class ClusterNode
/// <summary>
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
/// at apply time. Minimal by intent. Nullable when no overrides exist.
/// </summary>
public string? DriverConfigOverridesJson { get; set; }
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
/// Credentials bind to NodeId, not ClusterId.
/// </summary>
public sealed class ClusterNodeCredential
{
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// </summary>
/// <remarks>
/// <para>
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
/// drill-down). The publisher hosted service on the Server side subscribes to every
/// Supports the per-AppEngine Admin dashboard drill-down. The publisher hosted
/// service on the Server side subscribes to every
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
@@ -14,7 +14,7 @@ public sealed class DriverInstance
/// <summary>
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
/// <c>sp_ValidateDraft</c>: Namespace.ClusterId must equal DriverInstance.ClusterId.
/// </summary>
public required string NamespaceId { get; set; }
@@ -27,12 +27,12 @@ public sealed class DriverInstance
/// <summary>Gets or sets a value indicating whether this driver instance is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time.</summary>
public required string DriverConfig { get; set; }
/// <summary>
/// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
/// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
/// Null = use the driver's tier defaults. When populated, expected shape:
/// <code>
/// {
/// "bulkheadMaxConcurrent": 16,
@@ -2,8 +2,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
/// #138139 (OPC 40010 Identification fields as first-class columns).
/// </summary>
public sealed class Equipment
{
@@ -12,7 +10,7 @@ public sealed class Equipment
/// <summary>
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI.
/// </summary>
public required string EquipmentId { get; set; }
@@ -34,7 +32,7 @@ public sealed class Equipment
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
public required string Name { get; set; }
// Operator-facing / external-system identifiers (decision #116)
// Operator-facing / external-system identifiers
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
public required string MachineCode { get; set; }
@@ -45,7 +43,7 @@ public sealed class Equipment
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
public string? SAPID { get; set; }
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
// OPC UA Companion Spec OPC 40010 Machinery Identification fields.
// All nullable so equipment can be added before identity is fully captured.
/// <summary>Gets or sets the manufacturer name for this equipment.</summary>
public string? Manufacturer { get; set; }
@@ -66,7 +64,7 @@ public sealed class Equipment
/// <summary>Gets or sets the device manual URI for this equipment.</summary>
public string? DeviceManualUri { get; set; }
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
/// <summary>Nullable hook for future schemas-repo template ID.</summary>
public string? EquipmentClassRef { get; set; }
/// <summary>Gets or sets whether this equipment is enabled.</summary>
@@ -46,8 +46,8 @@ public sealed class EquipmentImportBatch
}
/// <summary>
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the decision #117
/// + decision #139 columns from the CSV importer's output + an
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the required
/// + optional columns from the CSV importer's output + an
/// <see cref="IsAccepted"/> flag + a <see cref="RejectReason"/> string the preview modal
/// renders.
/// </summary>
@@ -68,7 +68,6 @@ public sealed class EquipmentImportRow
/// <summary>Gets or sets the reason this row was rejected, if applicable.</summary>
public string? RejectReason { get; set; }
// Required (decision #117)
/// <summary>Gets or sets the Z tag identifier.</summary>
public required string ZTag { get; set; }
@@ -93,7 +92,6 @@ public sealed class EquipmentImportRow
/// <summary>Gets or sets the UNS line name.</summary>
public required string UnsLineName { get; set; }
// Optional (decision #139 — OPC 40010 Identification)
/// <summary>Gets or sets the manufacturer name.</summary>
public string? Manufacturer { get; set; }
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. NOT generation-versioned.
/// Exists outside generation flow specifically because old generations and disabled equipment can
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
/// </summary>
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// applies fleet-wide.
/// </summary>
/// <remarks>
/// <para>Per <c>docs/v2/plan.md</c> decisions #105 and #150 — this entity is <b>control-plane
/// <para>This entity is <b>control-plane
/// only</b>. The OPC UA data-path evaluator does not read these rows; it reads
/// <see cref="NodeAcl"/> joined directly against the session's resolved LDAP group
/// memberships. Collapsing the two would let a user inherit tag permissions via an
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123
/// OPC UA namespace served by a cluster. Generation-versioned —
/// namespaces are content (affect what consumers see at the endpoint), not topology.
/// </summary>
public sealed class Namespace
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
/// Generation-versioned per decision. See <c>acl-design.md</c> for evaluation algorithm.
/// </summary>
public sealed class NodeAcl
{
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #8 — user-authored C# script source, referenced by
/// User-authored C# script source, referenced by
/// <see cref="VirtualTag"/> and <see cref="ScriptedAlarm"/>. One row per script,
/// per generation. <c>SourceHash</c> is the compile-cache key.
/// </summary>
@@ -1,15 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decisions #5, #13, #15 — a scripted OPC UA Part 9 alarm whose
/// A scripted OPC UA Part 9 alarm whose
/// condition is the predicate <see cref="Script"/> referenced by
/// <see cref="PredicateScriptId"/>. Materialized by <c>Core.ScriptedAlarms</c> as a
/// concrete <c>AlarmConditionType</c> subtype per <see cref="AlarmType"/>.
/// </summary>
/// <remarks>
/// <para>
/// Message tokens (<c>{TagPath}</c>) resolved at emission time per plan decision #13.
/// <see cref="HistorizeToAveva"/> (plan decision #15) gates whether transitions
/// Message tokens (<c>{TagPath}</c>) resolved at emission time.
/// <see cref="HistorizeToAveva"/> gates whether transitions
/// route through the Core.AlarmHistorian SQLite queue + Galaxy.Host to the Aveva
/// Historian alarm schema.
/// </para>
@@ -41,7 +41,7 @@ public sealed class ScriptedAlarm
public required string PredicateScriptId { get; set; }
/// <summary>
/// Plan decision #15 — when true, transitions route through the SQLite store-and-forward
/// When true, transitions route through the SQLite store-and-forward
/// queue to the Aveva Historian. Defaults on for scripted alarms because they are the
/// primary motivation for the historian sink; operator can disable per alarm.
/// </summary>
@@ -1,8 +1,8 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #14 — persistent runtime state for each scripted alarm.
/// Survives process restart so operators don't re-ack and ack history survives for
/// Persistent runtime state for each scripted alarm. Survives process restart so
/// operators don't re-ack and ack history survives for
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
/// per-generation) because ack state follows the alarm's stable identity across
/// generations — a Modified alarm keeps its ack history.
@@ -24,7 +24,7 @@ public sealed class ScriptedAlarmState
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
public required string ScriptedAlarmId { get; set; }
/// <summary>Enabled/Disabled. Persists across restart per plan decision #14.</summary>
/// <summary>Enabled/Disabled. Persists across restart.</summary>
public required string EnabledState { get; set; } = "Enabled";
/// <summary>Unacknowledged / Acknowledged.</summary>
@@ -14,7 +14,7 @@ public sealed class ServerCluster
/// <summary>Gets or sets the display name for the server cluster.</summary>
public required string Name { get; set; }
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
/// <summary>UNS level 1. Canonical org value: "zb".</summary>
public required string Enterprise { get; set; }
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
@@ -51,7 +51,7 @@ public sealed class Tag
/// </summary>
public required TagAccessLevel AccessLevel { get; set; }
/// <summary>Per decisions #4445 — opt-in for write retry eligibility.</summary>
/// <summary>Opt-in for write retry eligibility.</summary>
public bool WriteIdempotent { get; set; }
/// <summary>
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
/// <summary>UNS level-3 segment. Generation-versioned.</summary>
public sealed class UnsArea
{
/// <summary>Gets or sets the unique row identifier.</summary>
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
/// <summary>UNS level-4 segment. Generation-versioned.</summary>
public sealed class UnsLine
{
/// <summary>Gets or sets the unique row identifier for this UNS line.</summary>
@@ -1,20 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per Phase 7 plan decision #2 — a virtual (calculated) tag that lives in the
/// Equipment tree alongside driver tags. Value is produced by the
/// <see cref="Script"/> referenced by <see cref="ScriptId"/>.
/// A virtual (calculated) tag that lives in the Equipment tree alongside driver tags.
/// Value is produced by the <see cref="Script"/> referenced by <see cref="ScriptId"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="EquipmentId"/> is mandatory — virtual tags are always scoped to an
/// Equipment node per plan decision #2 (unified Equipment tree, not a separate
/// Equipment node (unified Equipment tree, not a separate
/// /Virtual namespace). <see cref="DataType"/> matches the shape used by
/// <c>Tag.DataType</c>.
/// </para>
/// <para>
/// <see cref="ChangeTriggered"/> and <see cref="TimerIntervalMs"/> together realize
/// plan decision #3 (change + timer). At least one must produce evaluations; the
/// change + timer evaluation. At least one must produce evaluations; the
/// Core.VirtualTags engine rejects an all-disabled tag at load time.
/// </para>
/// </remarks>
@@ -8,14 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// </summary>
/// <remarks>
/// <para>
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
/// Per <c>docs/v2/plan.md</c> the two concerns share zero runtime code path:
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
/// table would collapse the distinction + let a user inherit tag permissions via their
/// admin-role claim path.
/// </para>
/// <para>
/// Task 1.7 standardized the member names on the canonical control-plane role vocabulary
/// The member names were standardized on the canonical control-plane role vocabulary
/// (<c>ZB.MOM.WW.Auth</c> <c>CanonicalRole</c>): <c>ConfigViewer → Viewer</c>,
/// <c>ConfigEditor → Designer</c>, <c>FleetAdmin → Administrator</c>. The appsettings-only
/// <c>DriverOperator</c> string role likewise became <c>Operator</c>. These members persist
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>. Per decision #83.</summary>
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>.</summary>
public enum CredentialKind
{
SqlLogin,
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>OPC UA namespace kind per decision #107. One of each kind per cluster per generation.</summary>
/// <summary>OPC UA namespace kind. One of each kind per cluster per generation.</summary>
public enum NamespaceKind
{
/// <summary>
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>External-ID reservation kind. Per decision #124.</summary>
/// <summary>External-ID reservation kind.</summary>
public enum ReservationKind
{
ZTag,
@@ -3,7 +3,7 @@ using LiteDB;
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
/// <summary>
/// Generation-sealed LiteDB cache per <c>docs/v2/plan.md</c> decision #148 and Phase 6.1
/// Generation-sealed LiteDB cache per <c>docs/v2/plan.md</c> and Phase 6.1
/// Stream D.1. Each published generation writes one <b>read-only</b> LiteDB file under
/// <c>&lt;cache-root&gt;/&lt;clusterId&gt;/&lt;generationId&gt;.db</c>. A per-cluster
/// <c>CURRENT</c> text file holds the currently-active generation id; it is updated
@@ -32,7 +32,7 @@ public sealed class GenerationSealedCache
// BsonMapper.Global is a process-wide singleton whose lazy per-type member registration is
// not thread-safe across concurrently-constructed LiteDatabase instances; a seal racing a
// read (or this cache racing LiteDbConfigCache) corrupts the global mapper, surfacing as
// "Member … not found on BsonMapper" or a bogus duplicate-_id insert — Configuration-012.
// "Member … not found on BsonMapper" or a bogus duplicate-_id insert.
private static BsonMapper BuildMapper()
{
var mapper = new BsonMapper();
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
/// <summary>
/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the
/// address space when the central DB is unreachable (decision #79 — degraded-but-running).
/// address space when the central DB is unreachable (degraded-but-running).
/// </summary>
/// <remarks>
/// <para><b>Concurrency contract:</b> implementations must serialize writes — specifically,
@@ -21,10 +21,12 @@ public interface ILocalConfigCache
/// <summary>Stores a generation snapshot in the local cache.</summary>
/// <param name="snapshot">The generation snapshot to store.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
/// <summary>Removes old generations, keeping only the most recent N.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <param name="keepLatest">The number of latest generations to keep.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
/// LiteDB-backed <see cref="ILocalConfigCache"/>. One file per node (default
/// <c>config_cache.db</c>), one collection per snapshot. Corruption surfaces as
/// <see cref="LocalConfigCacheCorruptException"/> on construction or read — callers should
/// delete and re-fetch from the central DB (decision #80).
/// delete and re-fetch from the central DB.
/// </summary>
public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
{
@@ -17,7 +17,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
// instances. When several caches (this one + GenerationSealedCache) initialise in parallel
// the global mapper races, surfacing as "Member ClusterId not found on BsonMapper" or a
// bogus "duplicate key _id = 0" (the int auto-id mapping was lost so Insert writes a literal
// 0 twice) — Configuration-012. Give each database a private, pre-registered mapper so member
// 0 twice). Give each database a private, pre-registered mapper so member
// resolution happens once, single-threaded, at construction and never touches the global.
private static BsonMapper BuildMapper()
{
@@ -30,7 +30,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
private readonly ILiteCollection<GenerationSnapshot> _col;
// PutAsync is a find-then-insert/update; without serialization, two concurrent puts for the
// same (ClusterId, GenerationId) can both observe `existing is null` and both Insert,
// producing duplicate rows (Configuration-005). Serialize writes through this semaphore so
// producing duplicate rows. Serialize writes through this semaphore so
// the read-modify-write block is atomic for a given instance. LiteDB itself only locks the
// page-level write, not the find-then-insert window.
private readonly SemaphoreSlim _writeGate = new(initialCount: 1, maxCount: 1);
@@ -60,9 +60,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
}
}
/// <summary>Gets the most recent snapshot for the specified cluster.</summary>
/// <param name="clusterId">The cluster ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
@@ -73,15 +71,13 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
return Task.FromResult<GenerationSnapshot?>(snapshot);
}
/// <summary>Stores a snapshot in the cache.</summary>
/// <param name="snapshot">The snapshot to store.</param>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
// Serialize the find-then-insert/update so concurrent callers do not observe a stale
// `existing is null` and both Insert (Configuration-005). LiteDB's per-call lock is
// not enough — the read and the write are independent calls.
// `existing is null` and both Insert. LiteDB's per-call lock is not enough — the
// read and the write are independent calls.
await _writeGate.WaitAsync(ct).ConfigureAwait(false);
try
{
@@ -104,10 +100,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
}
}
/// <summary>Removes old generation snapshots, keeping only the latest ones.</summary>
/// <param name="clusterId">The cluster ID.</param>
/// <param name="keepLatest">Number of latest generations to keep.</param>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
@@ -69,7 +69,7 @@ public sealed class ResilientConfigReader
}
/// <summary>
/// Configuration-010: redact connection-string fragments (Password, User Id, Pwd, etc.)
/// Redacts connection-string fragments (Password, User Id, Pwd, etc.)
/// that a caller's exception message could carry. Conservative regex pass — anything
/// matching <c>Key=Value</c> with a known credential key gets its value replaced.
/// </summary>
@@ -120,7 +120,7 @@ public sealed class ResilientConfigReader
// that case, not propagate. Only rethrow if the caller actually requested cancellation.
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
// Configuration-010: do NOT pass the raw exception object — it carries the stack
// Do NOT pass the raw exception object — it carries the stack
// and inner-exception chain, and SqlException/wrapping delegates can surface
// connection-string fragments (Password=…, User Id=…) embedded in messages.
// Log only the exception type and a scrubbed message so secrets stay out of logs.
@@ -72,8 +72,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
/// <summary>Gets the DbSet of data protection keys.</summary>
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
/// <summary>Configures the entity model when the context is first created.</summary>
/// <param name="modelBuilder">The model builder used to configure the context.</param>
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -151,13 +149,12 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
// Fleet-wide unique per decision #86
// Fleet-wide unique.
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
// v2: the "one Primary per cluster" filtered unique index (and the RedundancyRole
// column it filtered on) are gone. Akka cluster leader-of-driver-role is the
// authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator,
// Task 35).
// authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator).
});
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
/// <summary>
/// CRUD surface for <see cref="LdapGroupRoleMapping"/> — the control-plane mapping from
/// LDAP groups to Admin UI roles. Consumed only by Admin UI code paths; the OPC UA
/// data-path evaluator MUST NOT depend on this interface (see decision #150 and the
/// data-path evaluator MUST NOT depend on this interface (see the
/// Phase 6.2 compliance check on control/data-plane separation).
/// </summary>
/// <remarks>
@@ -28,11 +28,13 @@ public interface ILdapGroupRoleMappingService
/// </remarks>
/// <param name="ldapGroups">The LDAP groups to search for.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The mappings whose LDAP group matches one of <paramref name="ldapGroups"/>.</returns>
Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken);
/// <summary>Enumerate every mapping; Admin UI listing only.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Every LDAP group role mapping.</returns>
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
/// <summary>Create a new grant.</summary>
@@ -43,11 +45,13 @@ public interface ILdapGroupRoleMappingService
/// </exception>
/// <param name="row">The LDAP group role mapping to create.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The created mapping row.</returns>
Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken);
/// <summary>Delete a mapping by its surrogate key.</summary>
/// <param name="id">The unique identifier of the mapping to delete.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
}
@@ -10,10 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
/// </summary>
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
{
/// <summary>Gets LDAP group role mappings for the specified groups.</summary>
/// <param name="ldapGroups">The LDAP group names to query.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The matching role mappings.</returns>
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
{
@@ -28,9 +25,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
.ConfigureAwait(false);
}
/// <summary>Lists all LDAP group role mappings.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>All role mappings ordered by group and cluster ID.</returns>
/// <inheritdoc />
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
=> await db.LdapGroupRoleMappings
.AsNoTracking()
@@ -39,10 +34,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
/// <summary>Creates a new LDAP group role mapping.</summary>
/// <param name="row">The mapping to create.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The created mapping with generated ID and timestamp.</returns>
/// <inheritdoc />
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(row);
@@ -56,10 +48,7 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
return row;
}
/// <summary>Deletes an LDAP group role mapping.</summary>
/// <param name="id">The mapping identifier.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that completes when the deletion is done.</returns>
/// <inheritdoc />
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
{
var existing = await db.LdapGroupRoleMappings.FindAsync([id], cancellationToken).ConfigureAwait(false);
@@ -5,7 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// Managed-code pre-publish validator per decision #91. Complements the structural checks in
/// Managed-code pre-publish validator. Complements the structural checks in
/// <c>sp_ValidateDraft</c> — this layer owns schema validation for JSON columns, UNS segment
/// regex, EquipmentId derivation, cross-cluster checks, and anything else that's uncomfortable
/// to express in T-SQL. Returns every failing rule in one pass (decision: surface all errors,
@@ -21,6 +21,7 @@ public static class DraftValidator
/// Validates a draft snapshot and returns all validation errors found in a single pass.
/// </summary>
/// <param name="draft">The draft snapshot to validate.</param>
/// <returns>Every validation error found; empty when the draft is valid.</returns>
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
{
var errors = new List<ValidationError>();
@@ -204,8 +205,9 @@ public static class DraftValidator
}
}
/// <summary>Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
/// <summary>EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
/// <param name="uuid">The equipment UUID to derive the ID from.</param>
/// <returns>The derived <c>EQ-</c>-prefixed EquipmentId.</returns>
public static string DeriveEquipmentId(Guid uuid) =>
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
@@ -222,7 +224,7 @@ public static class DraftValidator
}
/// <summary>
/// Phase 6.3 Stream A.2 + task #148 part 2 — managed pre-publish guard for cluster
/// Managed pre-publish guard for cluster
/// topology vs. <see cref="ServerCluster.RedundancyMode"/>. The SQL
/// <c>CK_ServerCluster_RedundancyMode_NodeCount</c> CHECK already enforces the
/// (NodeCount, RedundancyMode) pair on the row itself, but it cannot see the
@@ -240,6 +242,8 @@ public static class DraftValidator
/// </remarks>
/// <param name="cluster">The server cluster to validate.</param>
/// <param name="clusterNodes">The cluster nodes to validate against the cluster configuration.</param>
/// <returns>Every failing topology rule found; empty when the cluster's declared and enabled-node
/// topology is consistent with its <see cref="ServerCluster.RedundancyMode"/>.</returns>
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
ServerCluster cluster,
IReadOnlyList<ClusterNode> clusterNodes)
@@ -273,7 +277,7 @@ public static class DraftValidator
cluster.ClusterId));
// v2: the v1 "exactly one Primary per cluster" invariant is gone. RedundancyRole was
// dropped in Task 14d; in v2 the Akka cluster's role-leader-of-"driver" elects the
// dropped; in v2 the Akka cluster's role-leader-of-"driver" elects the
// primary at runtime, so there is no static configuration to validate here.
return errors;
@@ -6,10 +6,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// shape so the node-manager can pass through quality, source timestamp, and
/// server timestamp without translation.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #13 — every driver maps to the same
/// OPC UA StatusCode space; this DTO is the universal carrier.
/// </remarks>
/// <param name="Value">The raw value; null when <see cref="StatusCode"/> indicates Bad.</param>
/// <param name="StatusCode">OPC UA status code (numeric value matches the OPC UA spec).</param>
/// <param name="SourceTimestampUtc">Driver-side timestamp when the value was sampled at the source. Null if unavailable.</param>
@@ -27,14 +27,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </param>
/// <param name="WriteIdempotent">
/// True when a timed-out or failed write to this attribute is safe to replay. Per
/// <c>docs/v2/plan.md</c> decisions #44, #45, #143 — writes are NOT auto-retried by default
/// <c>docs/v2/plan.md</c>, writes are NOT auto-retried by default
/// because replaying a pulse / alarm-ack / counter-increment / recipe-step advance can
/// duplicate field actions. Drivers flag only tags whose semantics make retry safe
/// (holding registers with level-set values, set-point writes to analog tags) — the
/// capability invoker respects this flag when deciding whether to apply Polly retry.
/// </param>
/// <param name="Source">
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
/// Discriminates which runtime subsystem owns this node's dispatch.
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
/// </param>
/// <param name="VirtualTagId">
@@ -59,7 +59,7 @@ public sealed record DriverAttributeInfo(
string? ScriptedAlarmId = null);
/// <summary>
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
/// Discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <see cref="IAlarmSource"/>, <see cref="IHistoryProvider"/>).
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #143 (per-capability retry policy): Read / HistoryRead /
/// Per the per-capability retry policy in <c>docs/v2/plan.md</c>: Read / HistoryRead /
/// Discover / Probe / AlarmSubscribe auto-retry; <see cref="Write"/> does NOT retry unless the
/// tag-definition carries <see cref="WriteIdempotentAttribute"/>. Alarm-acknowledge is treated
/// as a write for retry semantics (an alarm-ack is not idempotent at the plant-floor acknowledgement
@@ -34,7 +34,7 @@ public enum DriverCapability
/// <summary><see cref="IAlarmSource.SubscribeAlarmsAsync"/>. Retries by default.</summary>
AlarmSubscribe,
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation (decision #143).</summary>
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation.</summary>
AlarmAcknowledge,
/// <summary><see cref="IHistoryProvider"/> reads (Raw/Processed/AtTime/Events). Retries by default.</summary>
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// failure (useful for diagnostics), and <see cref="DriverState.Degraded"/> /
/// <see cref="DriverState.Reconnecting"/> / <see cref="DriverState.Faulted"/> states may all
/// carry a non-null message. Callers must not key behaviour on the LastError-null ↔ Healthy
/// pairing (Core.Abstractions-008).
/// pairing.
/// </param>
public sealed record DriverHealth(
DriverState State,
@@ -6,8 +6,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// supervision with process-level recycle is in play.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c> decisions #63-74.
///
/// <list type="bullet">
/// <item><b>A</b> — managed, known-good SDK; low blast radius. In-process. Fast retries.
/// Examples: OPC UA Client (OPCFoundation stack), S7 (S7NetPlus).</item>
@@ -18,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </list>
///
/// <para>Process-kill protections (<c>MemoryRecycle</c>, <c>ScheduledRecycleScheduler</c>) are
/// Tier C only per decisions #73-74 and #145 — killing an in-process Tier A/B driver also kills
/// Tier C only — killing an in-process Tier A/B driver also kills
/// every OPC UA session and every co-hosted driver, blast-radius worse than the leak.</para>
/// </remarks>
public enum DriverTier
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// the registry to validate <c>DriverInstance.DriverType</c> values from the central config DB.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #91 (JSON content validation in Admin app, not SQL CLR)
/// and #111 (driver type → namespace kind mapping enforced by sp_ValidateDraft).
/// The registry is the source of truth for both checks.
/// Per <c>docs/v2/plan.md</c> decisions on JSON content validation happening in the Admin app
/// (not SQL CLR), and on driver type → namespace kind mapping being enforced by
/// <c>sp_ValidateDraft</c>. The registry is the source of truth for both checks.
///
/// Thread-safety: registration is typically single-threaded at startup; lookups happen on
/// every config-apply (multi-threaded). The check-then-act inside <see cref="Register"/> is
@@ -28,7 +28,7 @@ public sealed class DriverTypeRegistry
/// <remarks>
/// The check-then-act (duplicate check → copy-on-write rebuild → swap) is performed under
/// <see cref="_writeLock"/> so concurrent <see cref="Register"/> calls cannot silently
/// discard each other's registrations — see Core.Abstractions-004.
/// discard each other's registrations.
/// </remarks>
/// <param name="metadata">The driver type metadata to register.</param>
public void Register(DriverTypeMetadata metadata)
@@ -55,6 +55,7 @@ public sealed class DriverTypeRegistry
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
/// <param name="driverType">The driver type name to look up.</param>
/// <returns>The registered metadata for the driver type.</returns>
public DriverTypeMetadata Get(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -69,6 +70,7 @@ public sealed class DriverTypeRegistry
/// <summary>Try to look up a driver type by name. Returns null if unknown (no exception).</summary>
/// <param name="driverType">The driver type name to look up.</param>
/// <returns>The registered metadata, or <see langword="null"/> if the type is not registered.</returns>
public DriverTypeMetadata? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -76,6 +78,7 @@ public sealed class DriverTypeRegistry
}
/// <summary>Snapshot of all registered driver types.</summary>
/// <returns>A snapshot collection of all registered driver type metadata.</returns>
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
}
@@ -86,8 +89,8 @@ public sealed class DriverTypeRegistry
/// <param name="DeviceConfigJsonSchema">JSON Schema for <c>DeviceConfig</c> (multi-device drivers); null if the driver has no device layer.</param>
/// <param name="TagConfigJsonSchema">JSON Schema for <c>TagConfig</c>; required for every driver since every driver has tags.</param>
/// <param name="Tier">
/// Stability tier per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c>
/// decisions #63-74. Drives the shared resilience pipeline defaults
/// Stability tier per <c>docs/v2/driver-stability.md</c> §2-4 and the tiering decisions in
/// <c>docs/v2/plan.md</c>. Drives the shared resilience pipeline defaults
/// (<see cref="Tier"/> × capability → <c>CapabilityPolicy</c>), the <c>MemoryTracking</c>
/// hybrid-formula constants, and whether process-level <c>MemoryRecycle</c> / scheduled-
/// recycle protections apply (Tier C only). Every registered driver type must declare one.
@@ -100,7 +103,7 @@ public sealed record DriverTypeMetadata(
string TagConfigJsonSchema,
DriverTier Tier);
/// <summary>Bitmask of namespace kinds a driver type may populate. Per decision #111.</summary>
/// <summary>Bitmask of namespace kinds a driver type may populate.</summary>
[Flags]
public enum NamespaceKindCompatibility
{
@@ -16,6 +16,7 @@ public sealed class EquipmentTagRefResolver<TDef> where TDef : class
private readonly Func<string, TDef?> _parseRef;
private readonly ConcurrentDictionary<string, TDef?> _cache = new(StringComparer.Ordinal);
/// <summary>Initializes a new instance of the <see cref="EquipmentTagRefResolver{TDef}"/> class.</summary>
/// <param name="byName">Authored tag-table lookup (returns null on miss).</param>
/// <param name="parseRef">Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null.</param>
public EquipmentTagRefResolver(Func<string, TDef?> byName, Func<string, TDef?> parseRef)
@@ -28,6 +28,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="endUtc">The end of the time range in UTC.</param>
/// <param name="maxValuesPerNode">The maximum number of values to return per node.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The raw historical samples for the tag over the requested range.</returns>
Task<HistoryReadResult> ReadRawAsync(
string fullReference,
DateTime startUtc,
@@ -46,6 +47,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="interval">The interval for bucketing samples.</param>
/// <param name="aggregate">The aggregation function to apply to each bucket.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>One aggregated sample per interval bucket over the requested range.</returns>
Task<HistoryReadResult> ReadProcessedAsync(
string fullReference,
DateTime startUtc,
@@ -63,6 +65,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="fullReference">The full reference of the tag to read.</param>
/// <param name="timestampsUtc">The list of timestamps to read values at.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>One sample per requested timestamp, in the same order as <paramref name="timestampsUtc"/>.</returns>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
@@ -77,7 +80,7 @@ public interface IHistorianDataSource : IDisposable
/// Note on parameter types — <paramref name="maxEvents"/> is <see cref="int"/> (not
/// <see cref="uint"/>) so callers can pass <c>0</c> or a negative value as a "use the
/// backend's default cap" sentinel; see <c>WonderwareHistorianClient</c> /
/// <c>HistorianDataSource</c> and Core.Abstractions-006 for the rationale. The sibling
/// <c>HistorianDataSource</c> for the rationale. The sibling
/// <see cref="ReadRawAsync"/> / <see cref="ReadProcessedAsync"/> use
/// <c>uint maxValuesPerNode</c> because their OPC UA HistoryRead surface has no
/// equivalent "use default" sentinel.
@@ -85,8 +88,7 @@ public interface IHistorianDataSource : IDisposable
/// This surface declares <see cref="ReadAtTimeAsync"/> and <see cref="ReadEventsAsync"/>
/// as required members — a server-side historian owns the full read surface, unlike
/// <see cref="IHistoryProvider"/> where the same two methods are optional default-impl
/// methods so legacy drivers can stay raw-only. The asymmetry is intentional
/// (Core.Abstractions-008).
/// methods so legacy drivers can stay raw-only. The asymmetry is intentional.
/// </remarks>
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
/// <param name="startUtc">The start of the time range in UTC.</param>
@@ -96,9 +98,10 @@ public interface IHistorianDataSource : IDisposable
/// default cap. When the backend cap truncates the result, implementations MUST set
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> to a non-null token so
/// callers can detect truncation and page — a null continuation point means all
/// matching events were returned. (Core.Abstractions-009.)
/// matching events were returned.
/// </param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The matching historical event records, with a continuation point if the result was truncated.</returns>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
@@ -110,5 +113,6 @@ public interface IHistorianDataSource : IDisposable
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
/// observation; never blocks on backend I/O.
/// </summary>
/// <returns>The current health snapshot for the historian backend.</returns>
HistorianHealthSnapshot GetHealthSnapshot();
}
@@ -16,6 +16,7 @@ public interface IHistorizationOutbox : IDisposable
/// <summary>Appends <paramref name="entry"/> to the tail of the durable buffer.</summary>
/// <param name="entry">The value record to buffer.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct);
/// <summary>
@@ -24,6 +25,7 @@ public interface IHistorizationOutbox : IDisposable
/// </summary>
/// <param name="max">Maximum number of entries to return; must be positive.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Up to <paramref name="max"/> oldest un-acked entries in FIFO order.</returns>
ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct);
/// <summary>
@@ -32,9 +34,11 @@ public interface IHistorizationOutbox : IDisposable
/// </summary>
/// <param name="id">The <see cref="HistorizationOutboxEntry.Id"/> to ack.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
ValueTask RemoveAsync(Guid id, CancellationToken ct);
/// <summary>Current number of un-acked entries held in the buffer.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>The current number of un-acked entries in the buffer.</returns>
ValueTask<int> CountAsync(CancellationToken ct);
}
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <para>
/// A process-wide singleton via <see cref="Instance"/> (private ctor): it carries no state
/// and is immutable, so one shared instance is safe to assign as the node-manager's
/// <c>HistorianDataSource</c> default until the Host wires a real source post-start (Task 5).
/// <c>HistorianDataSource</c> default until the Host wires a real source post-start.
/// </para>
/// </summary>
public sealed class NullHistorianDataSource : IHistorianDataSource
@@ -55,10 +55,7 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
int maxEvents,
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
/// <summary>
/// Returns a fully-disabled snapshot — no connections open, every counter zero, every nullable
/// field null, no cluster nodes. Pure; never blocks.
/// </summary>
/// <inheritdoc />
public HistorianHealthSnapshot GetHealthSnapshot() => new(
TotalQueries: 0,
TotalSuccesses: 0,
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// as it discovers nodes — no buffering of the whole tree.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #52 — drivers register nodes via this builder
/// rather than returning a tree object. Supports incremental / large address spaces
/// without forcing the driver to buffer the whole tree.
/// Drivers register nodes via this builder rather than returning a tree object,
/// supporting incremental / large address spaces without forcing the driver to
/// buffer the whole tree.
/// </remarks>
public interface IAddressSpaceBuilder
{
@@ -18,6 +18,7 @@ public interface IAddressSpaceBuilder
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
/// <returns>A child builder scoped to inside the new folder.</returns>
IAddressSpaceBuilder Folder(string browseName, string displayName);
/// <summary>
@@ -27,6 +28,7 @@ public interface IAddressSpaceBuilder
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
/// <returns>A handle for the registered variable, used by Core for subscription routing.</returns>
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
/// <summary>
@@ -56,6 +58,7 @@ public interface IVariableHandle
/// <c>Acknowledge</c>, <c>Deactivate</c>).
/// </summary>
/// <param name="info">The alarm condition information.</param>
/// <returns>The sink that receives lifecycle transitions for this alarm condition.</returns>
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
}
@@ -13,6 +13,7 @@ public interface IAlarmSource
/// </summary>
/// <param name="sourceNodeIds">The driver node IDs to subscribe to.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A handle identifying the created subscription.</returns>
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds,
CancellationToken cancellationToken);
@@ -20,11 +21,13 @@ public interface IAlarmSource
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
/// <param name="handle">The subscription handle returned from <see cref="SubscribeAlarmsAsync"/>.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
/// <param name="acknowledgements">The batch of alarm acknowledgement requests.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
CancellationToken cancellationToken);
@@ -8,10 +8,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <see cref="IHostConnectivityProbe"/>) are composable — a driver implements only what its
/// backend actually supports.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #4 (composable capability interfaces) and #53
/// (capability discovery via <c>is</c> checks — no redundant flag enum).
/// </remarks>
public interface IDriver
{
/// <summary>Stable logical ID of this driver instance, sourced from the central config DB.</summary>
@@ -23,6 +19,7 @@ public interface IDriver
/// <summary>Initialize the driver from its <c>DriverConfig</c> JSON; open connections; prepare for first use.</summary>
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>
@@ -37,13 +34,16 @@ public interface IDriver
/// </remarks>
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ShutdownAsync(CancellationToken cancellationToken);
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
/// <returns>The driver's current health snapshot.</returns>
DriverHealth GetHealth();
/// <summary>
@@ -56,6 +56,7 @@ public interface IDriver
/// allocation tracking". Tier C drivers (process-isolated) report through the same
/// interface but the cache-flush is internal to their host.
/// </remarks>
/// <returns>The approximate memory footprint in bytes.</returns>
long GetMemoryFootprint();
/// <summary>
@@ -63,5 +64,6 @@ public interface IDriver
/// Required-for-correctness state must NOT be flushed.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
}
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// JSON editor with schema-driven validation against the registered JSON schema.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #27 — driver-specific config editors are deferred
/// Driver-specific config editors are deferred
/// to each driver's implementation phase; v2.0 ships with the generic JSON editor as the
/// default. This interface is the future plug-point so phase-specific editors can land
/// incrementally.
@@ -34,12 +34,8 @@ public sealed class NullDriverFactory : IDriverFactory
public static readonly NullDriverFactory Instance = new();
private NullDriverFactory() { }
/// <summary>Creates a driver (always returns null in this null implementation).</summary>
/// <param name="driverType">The driver type name.</param>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <returns>Always returns null.</returns>
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
/// <summary>Gets the collection of supported driver types (empty in this null implementation).</summary>
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
}
@@ -11,6 +11,10 @@ public interface IDriverHealthPublisher
/// Publishes a health snapshot for one driver instance. Implementations must be
/// non-blocking and tolerant of being called from any thread.
/// </summary>
/// <param name="clusterId">The cluster the driver instance belongs to.</param>
/// <param name="driverInstanceId">The driver instance the snapshot describes.</param>
/// <param name="health">The current health snapshot.</param>
/// <param name="errorCount5Min">The number of errors observed in the trailing 5-minute window.</param>
void Publish(
string clusterId,
string driverInstanceId,
@@ -17,6 +17,10 @@ public interface IDriverProbe
/// timeout cancellation. Never throw on connection failure; instead return a result
/// with <c>Ok = false</c> + a message.
/// </summary>
/// <param name="configJson">The driver-specific configuration JSON to probe with.</param>
/// <param name="timeout">The maximum time to allow the probe to run.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The probe outcome, including success/failure and latency.</returns>
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
}
@@ -6,10 +6,10 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// hard fault is detected (memory breach, wedge, scheduled recycle window).
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #68, #73-74, and #145. Tier A/B drivers do NOT have
/// a supervisor because they run in-process — recycling would kill every OPC UA session and
/// every co-hosted driver. The Core.Stability layer only invokes this interface for Tier C
/// instances after asserting the tier via <see cref="DriverTypeMetadata.Tier"/>.
/// Tier A/B drivers do NOT have a supervisor because they run in-process — recycling would
/// kill every OPC UA session and every co-hosted driver. The Core.Stability layer only invokes
/// this interface for Tier C instances after asserting the tier via
/// <see cref="DriverTypeMetadata.Tier"/>.
/// </remarks>
public interface IDriverSupervisor
{
@@ -22,5 +22,6 @@ public interface IDriverSupervisor
/// </summary>
/// <param name="reason">Human-readable reason — flows into the supervisor's logs.</param>
/// <param name="cancellationToken">Cancels the recycle request; an in-flight restart is not interrupted.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task RecycleAsync(string reason, CancellationToken cancellationToken);
}
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// a raw-only driver compiles without forcing it to provide at-time / event surfaces it
/// has no backend for. The sibling server-side surface, <see cref="IHistorianDataSource"/>,
/// declares both methods as required because a registered historian owns the full read
/// surface; the asymmetry is intentional (Core.Abstractions-008).
/// surface; the asymmetry is intentional.
/// </remarks>
public interface IHistoryProvider
{
@@ -91,7 +91,7 @@ public interface IHistoryProvider
/// reads use for <c>maxValuesPerNode</c>) because callers and downstream historian
/// adapters historically treat <c>maxEvents &lt;= 0</c> as a sentinel meaning
/// "use the backend's default cap" (see <c>WonderwareHistorianClient</c> /
/// <c>HistorianDataSource</c>). The asymmetry is intentional — Core.Abstractions-006.
/// <c>HistorianDataSource</c>). The asymmetry is intentional.
///
/// <b>Continuation contract when using the sentinel:</b> When <c>maxEvents &lt;= 0</c>
/// the backend applies its own cap. If the backend's cap truncates the result
@@ -99,9 +99,9 @@ public interface IHistoryProvider
/// <see cref="HistoricalEventsResult.ContinuationPoint"/> to a non-null token so
/// callers can detect truncation and page. A null <c>ContinuationPoint</c> means all
/// matching events were returned — callers rely on this to decide whether to page.
/// (Core.Abstractions-009.)
/// </param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>A task that returns the historical events result containing matching event records and optional continuation point.</returns>
/// <remarks>
/// Default implementation throws. Drivers whose backend can serve historical events
/// override: Galaxy (Wonderware Alarm &amp; Events log) and the OPC UA Client driver
@@ -16,6 +16,7 @@ public interface IHostConnectivityProbe
/// Snapshot of host-level connectivity. The Core uses this to drive Bad-quality
/// fan-out scoped to the affected host's subtree (not the whole driver namespace).
/// </summary>
/// <returns>The current connectivity status of each known host.</returns>
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
@@ -9,8 +9,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </summary>
/// <remarks>
/// <para>Multi-host drivers (Modbus with N PLCs, hypothetical AB CIP across a rack, etc.)
/// implement this so the Phase 6.1 resilience pipeline can be keyed on
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c> per decision #144. One
/// implement this so the resilience pipeline can be keyed on
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c>. One
/// dead PLC behind a multi-device Modbus driver then trips only its own breaker; healthy
/// siblings keep serving.</para>
///
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </summary>
/// <remarks>
/// Reads are idempotent — Polly retry pipelines can safely retry on transient failures
/// (per <c>docs/v2/plan.md</c> decisions #34 and #44).
/// (per <c>docs/v2/plan.md</c>).
/// </remarks>
public interface IReadable
{
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// symbol-version-changed) implement this to tell Core when to re-run discovery.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #54 — static drivers (Modbus, S7, etc. whose tags
/// Per <c>docs/v2/plan.md</c> — static drivers (Modbus, S7, etc. whose tags
/// only change via a published config generation) don't implement <c>IRediscoverable</c>.
/// The Core just sees absence of the interface and skips change-detection wiring for that driver.
/// </remarks>
@@ -15,7 +15,7 @@ public enum DiscoveryRediscoverPolicy
/// <summary>
/// Driver capability for discovering tags and hierarchy from the backend.
/// Streams discovered nodes into <see cref="IAddressSpaceBuilder"/> rather than
/// buffering the entire tree (decision #52 — supports incremental / large address spaces).
/// buffering the entire tree (supports incremental / large address spaces).
/// </summary>
public interface ITagDiscovery
{
@@ -25,6 +25,7 @@ public interface ITagDiscovery
/// </summary>
/// <param name="builder">The address space builder to stream discovered nodes into.</param>
/// <param name="cancellationToken">A cancellation token for the discovery operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
/// <summary>Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior.</summary>
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// historian-only adapter, for example) can omit this.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #44 + #45 — <b>writes are NOT auto-retried by default</b>.
/// Per <c>docs/v2/plan.md</c>, <b>writes are NOT auto-retried by default</b>.
/// A timeout may fire after the device already accepted the command; replaying non-idempotent
/// field actions (pulses, alarm acks, recipe steps, counter increments) can cause duplicate
/// operations. Per-tag opt-in via <c>Tag.WriteIdempotent = true</c> in the central config DB
@@ -19,6 +19,7 @@ public interface IWritable
/// </summary>
/// <param name="writes">Pairs of full reference + value to write.</param>
/// <param name="cancellationToken">Cancellation token; the driver should abort the batch if cancelled.</param>
/// <returns>One <see cref="WriteResult"/> per requested write, in the same order.</returns>
Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken);
@@ -2,8 +2,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Every OPC UA operation surface the Phase 6.2 authorization evaluator gates, per
/// <c>docs/v2/implementation/phase-6-2-authorization-runtime.md</c> §Stream C and
/// decision #143. The evaluator maps each operation onto the corresponding
/// <c>docs/v2/implementation/phase-6-2-authorization-runtime.md</c> §Stream C.
/// The evaluator maps each operation onto the corresponding
/// <c>NodePermissions</c> bit(s) to decide whether the calling session is allowed.
/// </summary>
/// <remarks>

Some files were not shown because too many files have changed in this diff Show More