1 Commits

Author SHA1 Message Date
Joseph Doherty bd6c0b4d3d docs: complete XML doc comments via fixdocs (2757 to 131 findings)
Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up
misused inheritdoc across 481 files so the documented API surface is
complete. Documentation-only (zero code lines changed). The 131 remaining
findings are inheritdoc-style warnings deliberately left to preserve
hand-written implementation rationale (plan-decision notes, race-condition
explanations).
2026-06-03 12:34:34 -04:00
481 changed files with 2550 additions and 1668 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 value task that represents the asynchronous command execution.</returns>
public abstract ValueTask ExecuteAsync(IConsole console);
/// <summary>
/// Creates a <see cref="ConnectionSettings" /> from the common command options.
/// </summary>
/// <returns>A <see cref="ConnectionSettings"/> populated from the current command option values.</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>A tuple of the connected <see cref="IOpcUaClientService"/> and the resulting <see cref="ConnectionInfo"/>.</returns>
protected async Task<(IOpcUaClientService Service, ConnectionInfo Info)> CreateServiceAndConnectAsync(
CancellationToken ct)
{
@@ -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 <see cref="EndpointDescription"/> with its URL rewritten to the requested host.</returns>
public static EndpointDescription SelectBest(
IEnumerable<EndpointDescription> allEndpoints,
string endpointUrl,
@@ -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>A task that resolves to 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 for the requested security mode.</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>A task that resolves to the current <see cref="DataValue"/> for the node.</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>A task that resolves to the OPC UA <see cref="StatusCode"/> 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 task that resolves to a tuple of an optional continuation point and the returned references.</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>A task that resolves to a tuple of an optional next continuation point and the returned 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>A task that resolves to <see langword="true"/> if the node has at least one child; otherwise <see langword="false"/>.</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>A task that resolves to the ordered list of raw historical data values.</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>A task that resolves to the ordered list of processed aggregate data values.</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>A task that resolves to the newly created <see cref="ISubscriptionAdapter"/>.</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>A task that resolves to the list of output arguments returned by the method, or <see langword="null"/> if none.</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);
}
@@ -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 top-level folder under LocalApplicationData.</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 PKI store subfolder.</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><see langword="true"/> when the legacy folder was found and moved; <see langword="false"/> when no migration was needed.</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>A <see cref="ConnectionInfo"/> describing the active session after a successful connect.</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 <see cref="DataValue"/> including value, status code, and timestamps.</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 OPC UA <see cref="StatusCode"/> returned by the server for the write operation.</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 list of child nodes discovered under the specified parent.</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>
@@ -111,6 +121,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 <see cref="DataValue"/> samples in the requested range.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
int maxValues = 1000, CancellationToken ct = default);
@@ -123,6 +134,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 processed historical <see cref="DataValue"/> samples computed by the requested aggregate.</returns>
Task<IReadOnlyList<DataValue>> HistoryReadAggregateAsync(NodeId nodeId, DateTime startTime, DateTime endTime,
AggregateType aggregate, double intervalMs = 3600000, CancellationToken ct = default);
@@ -130,6 +142,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>A <see cref="RedundancyInfo"/> snapshot containing redundancy mode, service level, and partner endpoint URIs.</returns>
Task<RedundancyInfo> GetRedundancyInfoAsync(CancellationToken ct = default);
/// <summary>
@@ -73,13 +73,13 @@ public sealed class OpcUaClientService : IOpcUaClientService
{
}
/// <inheritdoc />
/// <summary>Raised when subscribed node values change.</summary>
public event EventHandler<DataChangedEventArgs>? DataChanged;
/// <inheritdoc />
/// <summary>Raised when an alarm event is received from the server.</summary>
public event EventHandler<AlarmEventArgs>? AlarmEvent;
/// <inheritdoc />
/// <summary>Raised when the connection state changes.</summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
@@ -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 persisted <see cref="UserSettings"/>, or a default instance if none are saved.</returns>
UserSettings Load();
/// <summary>Saves user settings to persistent storage.</summary>
/// <param name="settings">The settings to save.</param>
@@ -19,8 +19,7 @@ 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 />
public UserSettings Load()
{
try
@@ -37,8 +36,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();
@@ -195,6 +195,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 string, or null if not currently 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 list of node ID strings for 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 of (success flag, operator-readable message) describing the outcome of the write.</returns>
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
{
try
@@ -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>An array of validated, distinct, lower-cased role names; empty array when the input is null or whitespace.</returns>
public static string[] Parse(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
@@ -18,6 +18,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 <see cref="IServiceCollection"/> for chaining.</returns>
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<AkkaClusterOptions>()
@@ -45,6 +46,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 <see cref="AkkaConfigurationBuilder"/> 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>A task that resolves to the list of 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 identifier of the node whose children to expand.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the list of direct child nodes.</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 identifier of the node whose attributes to retrieve.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the list of attribute descriptors for 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>A task containing the opened browse session.</returns>
Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken);
}
@@ -18,6 +18,7 @@ public interface IAlarmActorStateStore
/// <summary>Saves the alarm actor state snapshot.</summary>
/// <param name="snapshot">The state snapshot to persist.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
}
@@ -41,14 +42,10 @@ public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
{
public static readonly NullAlarmActorStateStore Instance = new();
private NullAlarmActorStateStore() { }
/// <summary>Always returns null, indicating no persisted state.</summary>
/// <param name="alarmId">The alarm identifier (unused).</param>
/// <param name="ct">Cancellation token (unused).</param>
/// <inheritdoc />
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
Task.FromResult<AlarmActorStateSnapshot?>(null);
/// <summary>Completes immediately without persisting anything.</summary>
/// <param name="snapshot">The state snapshot (ignored).</param>
/// <param name="ct">Cancellation token (unused).</param>
/// <inheritdoc />
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
Task.CompletedTask;
}
@@ -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;
}
@@ -23,5 +23,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>A task that resolves to the reply of type <typeparamref name="T"/>.</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>A task that resolves to the diagnostics snapshot for the specified 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 <see cref="Activity"/>, or null 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 (Phase7 plan → apply).</summary>
/// <returns>The started <see cref="Activity"/>, or null when no listener is attached.</returns>
public static Activity? StartAddressSpaceRebuildSpan()
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
}
@@ -22,37 +22,22 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
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 an alarm state through the inner sink.</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="active">Whether the alarm is active.</param>
/// <param name="acknowledged">Whether the alarm has been acknowledged.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc)
=> _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc);
/// <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>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
/// <summary>Rebuilds the address space through the inner sink.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
}
@@ -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);
}
@@ -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 <see cref="CorrelationId"/> backed by a random GUID.</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>A <see cref="CorrelationId"/> parsed from the supplied string.</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><see langword="true"/> if parsing succeeded; otherwise <see langword="false"/>.</returns>
public static bool TryParse(string? s, out CorrelationId id)
{
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
@@ -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);
}
@@ -45,9 +45,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();
@@ -58,9 +56,7 @@ 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();
@@ -89,10 +85,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();
@@ -24,11 +24,13 @@ public interface ILdapGroupRoleMappingService
/// </remarks>
/// <param name="ldapGroups">The LDAP groups to search for.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task resolving to the list of mappings whose LDAP group matches any of the provided groups.</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>A task resolving to all LDAP group role mappings.</returns>
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
/// <summary>Create a new grant.</summary>
@@ -39,11 +41,13 @@ public interface ILdapGroupRoleMappingService
/// </exception>
/// <param name="row">The LDAP group role mapping to create.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task resolving to the newly created <see cref="LdapGroupRoleMapping"/> with any DB-assigned values populated.</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 delete 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)
{
@@ -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>A read-only list of all validation errors found; empty if the draft is valid.</returns>
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
{
var errors = new List<ValidationError>();
@@ -147,6 +148,7 @@ public static class DraftValidator
/// <summary>Decision #125: 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 equipment ID string in the form <c>EQ-xxxxxxxxxxxx</c>.</returns>
public static string DeriveEquipmentId(Guid uuid) =>
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
@@ -203,6 +205,7 @@ 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>A read-only list of all validation errors found; empty if the topology is valid.</returns>
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
ServerCluster cluster,
IReadOnlyList<ClusterNode> clusterNodes)
@@ -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 <see cref="DriverTypeMetadata"/> registered for the specified type name.</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 matching <see cref="DriverTypeMetadata"/>, or <c>null</c> if 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 read-only collection of all currently registered driver type metadata entries.</returns>
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
}
@@ -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>A task resolving to a <see cref="HistoryReadResult"/> containing the raw samples.</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>A task resolving to a <see cref="HistoryReadResult"/> containing the processed interval samples.</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>A task resolving to a <see cref="HistoryReadResult"/> with one sample per requested timestamp.</returns>
Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference,
IReadOnlyList<DateTime> timestampsUtc,
@@ -93,6 +96,7 @@ public interface IHistorianDataSource : IDisposable
/// <param name="endUtc">The end of the time range in UTC.</param>
/// <param name="maxEvents">The maximum number of events to return, or a non-positive value to use the default backend cap.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task resolving to a <see cref="HistoricalEventsResult"/> containing historical alarm and event records.</returns>
Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName,
DateTime startUtc,
@@ -104,5 +108,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 <see cref="HistorianHealthSnapshot"/> for this data source.</returns>
HistorianHealthSnapshot GetHealthSnapshot();
}
@@ -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 this 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>An opaque handle for the registered variable.</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>A sink that receives alarm lifecycle transitions for this 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 task that resolves to an opaque <see cref="IAlarmSubscriptionHandle"/> for the new 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);
@@ -23,6 +23,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 +38,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 current driver health snapshot.</returns>
DriverHealth GetHealth();
/// <summary>
@@ -56,6 +60,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 driver-attributable memory footprint in bytes.</returns>
long GetMemoryFootprint();
/// <summary>
@@ -63,5 +68,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);
}
@@ -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 identifier the driver instance belongs to.</param>
/// <param name="driverInstanceId">The unique identifier of the driver instance.</param>
/// <param name="health">The current health state of the driver instance.</param>
/// <param name="errorCount5Min">Number of errors recorded in the past 5 minutes.</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">Driver configuration JSON; same shape the runtime driver consumes.</param>
/// <param name="timeout">Maximum duration for the probe attempt.</param>
/// <param name="ct">Cancellation token for the probe operation.</param>
/// <returns>A task containing the probe result with success status and optional latency.</returns>
Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct);
}
@@ -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);
}
@@ -94,6 +94,7 @@ public interface IHistoryProvider
/// <c>HistorianDataSource</c>). The asymmetry is intentional — Core.Abstractions-006.
/// </param>
/// <param name="cancellationToken">Request cancellation.</param>
/// <returns>A task that resolves to the historical events result for the requested window.</returns>
/// <remarks>
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
/// Wonderware Alarm &amp; Events log) override. Modbus / the OPC UA Client driver stay
@@ -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>A snapshot list of per-host connectivity statuses.</returns>
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
@@ -13,5 +13,6 @@ 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 discovery operation.</returns>
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
}
@@ -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>A task that resolves to one <see cref="WriteResult"/> per requested write, in the same order.</returns>
Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken);
@@ -41,6 +41,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
/// <summary>Initializes a new poll-group engine with the supplied reader, change callback, interval floor, and optional error sink.</summary>
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
/// order as the input references.</param>
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
@@ -68,6 +69,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
/// <summary>Register a new polled subscription and start its background loop.</summary>
/// <param name="fullReferences">The list of tag references to poll.</param>
/// <param name="publishingInterval">The desired polling interval; will be clamped to the configured minimum.</param>
/// <returns>A subscription handle that can be passed to <see cref="Unsubscribe"/> to cancel the loop.</returns>
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
{
ArgumentNullException.ThrowIfNull(fullReferences);
@@ -207,6 +209,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
}
/// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
/// <returns>A value task that represents the asynchronous dispose operation.</returns>
public async ValueTask DisposeAsync()
{
// Cancel all loops first so they can all start winding down in parallel.
@@ -253,7 +256,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
{
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
/// <inheritdoc />
public string DiagnosticId => $"poll-sub-{Id}";
}
}
@@ -26,9 +26,11 @@ public interface IAlarmHistorianSink
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
/// <param name="evt">The alarm historian event to enqueue.</param>
/// <param name="cancellationToken">A cancellation token for async operations.</param>
/// <returns>A task that represents the asynchronous enqueue operation.</returns>
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
/// <summary>Snapshot of current queue depth + drain health.</summary>
/// <returns>A snapshot of the current queue depth and drain state.</returns>
HistorianSinkStatus GetStatus();
}
@@ -97,6 +99,7 @@ public interface IAlarmHistorianWriter
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
/// <param name="batch">The batch of alarm historian events to write.</param>
/// <param name="cancellationToken">A cancellation token for async operations.</param>
/// <returns>A task that resolves to one write outcome per event, in the same order as the batch.</returns>
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
}
@@ -255,6 +255,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
/// </remarks>
/// <param name="evt">The alarm historian event to enqueue.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
{
if (evt is null) throw new ArgumentNullException(nameof(evt));
@@ -345,6 +346,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
/// connections per tick, each paying the open + PRAGMA cost.
/// </remarks>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DrainOnceAsync(CancellationToken ct)
{
if (_disposed) return;
@@ -490,7 +492,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
}
/// <summary>Gets the current status of the historian sink including queue depth and drain state.</summary>
/// <inheritdoc />
public HistorianSinkStatus GetStatus()
{
// Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
@@ -534,6 +536,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
}
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
/// <returns>The number of rows moved back to the active queue.</returns>
public int RetryDeadLettered()
{
using var conn = OpenConnection();
@@ -97,6 +97,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// copy under the gate. (Core.ScriptedAlarms-013.)
/// </remarks>
/// <param name="alarmId">The alarm identifier to look up.</param>
/// <returns>The live read-cache dictionary for the alarm, or <see langword="null"/> if not yet allocated.</returns>
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
@@ -113,6 +114,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// (Core.ScriptedAlarms-013.)
/// </remarks>
/// <param name="alarmId">The alarm identifier to look up.</param>
/// <returns>The reusable <see cref="AlarmPredicateContext"/> for the alarm, or <see langword="null"/> if not yet allocated.</returns>
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
@@ -175,6 +177,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// </summary>
/// <param name="definitions">The alarm definitions to load.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
@@ -306,10 +309,12 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
/// </summary>
/// <param name="alarmId">The alarm identifier.</param>
/// <returns>The current <see cref="AlarmConditionState"/> for the alarm, or <see langword="null"/> if the alarm is unknown.</returns>
public AlarmConditionState? GetState(string alarmId)
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
/// <summary>Gets the current persisted state for all loaded alarms.</summary>
/// <returns>A snapshot collection of all current alarm condition states.</returns>
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray();
@@ -318,6 +323,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the acknowledgment.</param>
/// <param name="comment">An optional comment to attach to the acknowledgment.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
@@ -326,6 +332,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the confirmation.</param>
/// <param name="comment">An optional comment to attach to the confirmation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
@@ -333,6 +340,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the shelve operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
@@ -341,6 +349,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user performing the shelve operation.</param>
/// <param name="unshelveAtUtc">The UTC time at which the shelve will automatically expire.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
@@ -348,6 +357,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the unshelve operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
@@ -355,6 +365,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the enable operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
@@ -362,6 +373,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the disable operation.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
@@ -370,6 +382,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
/// <param name="user">The user adding the comment.</param>
/// <param name="text">The comment text.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
@@ -39,6 +39,7 @@ public static class DependencyExtractor
/// paths, or a list of rejection messages if non-literal paths were used.
/// </summary>
/// <param name="scriptSource">The script source code to analyze.</param>
/// <returns>The extracted dependency paths, or rejection messages for unsupported patterns.</returns>
public static DependencyExtractionResult Extract(string scriptSource)
{
if (string.IsNullOrWhiteSpace(scriptSource))
@@ -41,6 +41,7 @@ public abstract class ScriptContext
/// right upstream tags at load time.
/// </remarks>
/// <param name="path">The literal tag path to read.</param>
/// <returns>The current <see cref="DataValueSnapshot"/> for the tag, including value, quality, and timestamp.</returns>
public abstract DataValueSnapshot GetTag(string path);
/// <summary>
@@ -81,6 +82,7 @@ public abstract class ScriptContext
/// <param name="current">The current value to check.</param>
/// <param name="previous">The previous value to compare against.</param>
/// <param name="tolerance">The minimum difference threshold for a change to be detected.</param>
/// <returns><see langword="true"/> when the absolute difference between current and previous exceeds tolerance.</returns>
public static bool Deadband(double current, double previous, double tolerance)
=> Math.Abs(current - previous) > tolerance;
}
@@ -66,6 +66,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
/// <summary>Compiles user script source into an evaluator.</summary>
/// <param name="scriptSource">The user script source code to compile.</param>
/// <returns>A compiled <see cref="ScriptEvaluator{TContext, TResult}"/> ready to invoke.</returns>
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
{
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
@@ -173,6 +174,7 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
/// <summary>Runs the script against an already-constructed context.</summary>
/// <param name="context">The script context.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that resolves to the script's return value.</returns>
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
{
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
@@ -43,6 +43,7 @@ public static class ScriptSandbox
/// to resolve <c>ctx.GetTag(...)</c> calls.
/// </summary>
/// <param name="contextType">The concrete script context type to use for compilation.</param>
/// <returns>The sandbox configuration for compiling scripts with the given context type.</returns>
public static SandboxConfig Build(Type contextType)
{
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
@@ -156,6 +156,7 @@ public sealed class DependencyGraph
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
/// exists. Implemented via Kahn's algorithm.
/// </summary>
/// <returns>A list of node IDs in topological evaluation order.</returns>
public IReadOnlyList<string> TopologicalSort()
{
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
@@ -205,6 +206,7 @@ public sealed class DependencyGraph
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
/// rejection pass so operators see all of them, not just one at a time.
/// </summary>
/// <returns>A list of strongly-connected components that form cycles; empty if the graph is acyclic.</returns>
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
{
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
@@ -30,6 +30,7 @@ public interface ITagUpstreamSource
/// when the path isn't configured.
/// </summary>
/// <param name="path">The tag path to read.</param>
/// <returns>The last-known value and quality snapshot for the tag.</returns>
DataValueSnapshot ReadTag(string path);
/// <summary>
@@ -40,5 +41,6 @@ public interface ITagUpstreamSource
/// </summary>
/// <param name="path">The tag path to subscribe to.</param>
/// <param name="observer">The callback to invoke when the value changes.</param>
/// <returns>An <see cref="IDisposable"/> that cancels the subscription when disposed.</returns>
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
}
@@ -198,6 +198,7 @@ public sealed class VirtualTagEngine : IDisposable
/// default. Also called after a config reload.
/// </summary>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EvaluateAllAsync(CancellationToken ct = default)
{
EnsureLoaded();
@@ -212,6 +213,7 @@ public sealed class VirtualTagEngine : IDisposable
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
/// <param name="path">Path of the virtual tag to evaluate.</param>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
{
EnsureLoaded();
@@ -226,6 +228,7 @@ public sealed class VirtualTagEngine : IDisposable
/// evaluation result.
/// </summary>
/// <param name="path">Path of the tag to read.</param>
/// <returns>The most recently cached value and quality for the tag path.</returns>
public DataValueSnapshot Read(string path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -242,6 +245,7 @@ public sealed class VirtualTagEngine : IDisposable
/// </summary>
/// <param name="path">Path of the tag to subscribe to.</param>
/// <param name="observer">Callback invoked with the tag path and new value on each evaluation.</param>
/// <returns>An <see cref="IDisposable"/> that cancels the subscription when disposed.</returns>
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
{
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
@@ -19,6 +19,7 @@ public sealed record AuthorizationDecision(
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
/// <returns>An <see cref="AuthorizationDecision"/> with <see cref="AuthorizationVerdict.NotGranted"/> and empty provenance.</returns>
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
/// <summary>Allow with the list of grants that matched.</summary>
@@ -22,5 +22,6 @@ public interface IPermissionEvaluator
/// <param name="session">The user session containing resolved LDAP groups and roles.</param>
/// <param name="operation">The OPC UA operation being requested.</param>
/// <param name="scope">The node address scope being accessed.</param>
/// <returns>An <see cref="AuthorizationDecision"/> indicating whether the operation is allowed.</returns>
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
}
@@ -33,6 +33,7 @@ public sealed class PermissionTrie
/// </summary>
/// <param name="scope">The node scope to match permissions for.</param>
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
/// <returns>The list of grants that apply to the given scope for any of the session's LDAP groups.</returns>
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
{
ArgumentNullException.ThrowIfNull(scope);
@@ -41,6 +41,7 @@ public static class PermissionTrieBuilder
/// Core-011 production hazard. The callback fires only when <paramref name="scopePaths"/>
/// is non-null (a null lookup is the explicit deterministic-test fallback mode).
/// </param>
/// <returns>An immutable <see cref="PermissionTrie"/> for the given cluster and generation.</returns>
public static PermissionTrie Build(
string clusterId,
long generationId,
@@ -34,6 +34,7 @@ public sealed class PermissionTrieCache
/// <summary>Get the current-generation trie for a cluster; null when nothing installed.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current-generation trie, or null if nothing is installed for the cluster.</returns>
public PermissionTrie? GetTrie(string clusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
@@ -43,6 +44,7 @@ public sealed class PermissionTrieCache
/// <summary>Get a specific (cluster, generation) trie; null if that pair isn't cached.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <param name="generationId">The generation identifier.</param>
/// <returns>The trie for the specified cluster and generation, or null if not cached.</returns>
public PermissionTrie? GetTrie(string clusterId, long generationId)
{
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
@@ -51,6 +53,7 @@ public sealed class PermissionTrieCache
/// <summary>The generation id the <see cref="GetTrie(string)"/> shortcut currently serves for a cluster.</summary>
/// <param name="clusterId">The cluster identifier.</param>
/// <returns>The current generation ID, or null if no trie is installed for the cluster.</returns>
public long? CurrentGenerationId(string clusterId)
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
@@ -111,11 +114,13 @@ public sealed class PermissionTrieCache
/// <summary>Creates a cluster entry from a single trie.</summary>
/// <param name="trie">The permission trie to create the entry from.</param>
/// <returns>A new <see cref="ClusterEntry"/> containing the single trie as the current generation.</returns>
public static ClusterEntry FromSingle(PermissionTrie trie) =>
new(trie, new Dictionary<long, PermissionTrie> { [trie.GenerationId] = trie });
/// <summary>Creates a new entry with an additional trie, updating current if it's newer.</summary>
/// <param name="trie">The new permission trie to add.</param>
/// <returns>A new <see cref="ClusterEntry"/> with the trie added and the current pointer updated if the new generation is newer.</returns>
public ClusterEntry WithAdditional(PermissionTrie trie)
{
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
@@ -24,11 +24,7 @@ public sealed class TriePermissionEvaluator : IPermissionEvaluator
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>Authorizes an operation against the user's session and node scope.</summary>
/// <param name="session">The user's authorization session.</param>
/// <param name="operation">The OPC UA operation to authorize.</param>
/// <param name="scope">The target node scope.</param>
/// <returns>An authorization decision indicating whether the operation is allowed.</returns>
/// <inheritdoc />
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
{
ArgumentNullException.ThrowIfNull(session);
@@ -64,6 +64,7 @@ public sealed record UserAuthorizationState
/// whenever this is true.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><c>true</c> when the state exceeds its maximum staleness ceiling.</returns>
public bool IsStale(DateTime utcNow) => utcNow - MembershipResolvedUtc > AuthCacheMaxStaleness;
/// <summary>
@@ -72,6 +73,7 @@ public sealed record UserAuthorizationState
/// call still evaluates against the cached memberships.
/// </summary>
/// <param name="utcNow">The current UTC time.</param>
/// <returns><c>true</c> when a background refresh should be initiated but the current cached memberships are still usable.</returns>
public bool NeedsRefresh(DateTime utcNow) =>
!IsStale(utcNow) && utcNow - MembershipResolvedUtc > MembershipFreshnessInterval;
}
@@ -63,6 +63,7 @@ public sealed class DriverFactoryRegistry
/// missing-assembly deployment doesn't take down the whole server.
/// </summary>
/// <param name="driverType">The driver type to look up.</param>
/// <returns>The registered factory delegate, or <see langword="null"/> if no factory was registered for the type.</returns>
public Func<string, string, IDriver>? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -75,6 +76,7 @@ public sealed class DriverFactoryRegistry
/// case upstream; we don't double-surface that failure here.
/// </summary>
/// <param name="driverType">The driver type to look up.</param>
/// <returns>The registered <see cref="DriverTier"/>, or <see cref="DriverTier.A"/> if the type is unknown.</returns>
public DriverTier GetTier(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
@@ -20,16 +20,13 @@ public sealed class DriverFactoryRegistryAdapter : IDriverFactory
_registry = registry;
}
/// <summary>Attempts to create a driver instance by type and configuration.</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>
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
var factory = _registry.TryGet(driverType);
return factory?.Invoke(driverInstanceId, driverConfigJson);
}
/// <summary>Gets the collection of supported driver type names.</summary>
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => _registry.RegisteredTypes;
}
@@ -21,6 +21,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <summary>Gets the health status of a registered driver.</summary>
/// <param name="driverInstanceId">The driver instance identifier to query.</param>
/// <returns>The driver health if the driver is registered; otherwise null.</returns>
public DriverHealth? GetHealth(string driverInstanceId)
{
lock (_lock)
@@ -33,6 +34,7 @@ public sealed class DriverHost : IAsyncDisposable
/// startup. Returns null when the driver is not registered.
/// </summary>
/// <param name="driverInstanceId">The driver instance identifier to look up.</param>
/// <returns>The driver instance if registered; otherwise null.</returns>
public IDriver? GetDriver(string driverInstanceId)
{
lock (_lock)
@@ -47,6 +49,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <param name="driver">The driver instance to register.</param>
/// <param name="driverConfigJson">The configuration JSON for the driver.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RegisterAsync(IDriver driver, string driverConfigJson, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(driver);
@@ -70,6 +73,7 @@ public sealed class DriverHost : IAsyncDisposable
/// <summary>Unregisters a driver and calls shutdown.</summary>
/// <param name="driverInstanceId">The driver instance identifier to unregister.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UnregisterAsync(string driverInstanceId, CancellationToken ct)
{
IDriver? driver;
@@ -84,6 +88,7 @@ public sealed class DriverHost : IAsyncDisposable
}
/// <summary>Disposes the driver host and all registered drivers.</summary>
/// <returns>A value task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
List<IDriver> snapshot;
@@ -27,6 +27,7 @@ public static class DriverHealthReport
{
/// <summary>Compute the fleet-wide readiness verdict from per-driver states.</summary>
/// <param name="drivers">The list of per-driver health snapshots to aggregate.</param>
/// <returns>The fleet-wide <see cref="ReadinessVerdict"/> derived from all driver states.</returns>
public static ReadinessVerdict Aggregate(IReadOnlyList<DriverHealthSnapshot> drivers)
{
ArgumentNullException.ThrowIfNull(drivers);
@@ -54,6 +55,7 @@ public static class DriverHealthReport
/// return per the Stream C.1 state matrix.
/// </summary>
/// <param name="verdict">The readiness verdict to map to HTTP status.</param>
/// <returns>The HTTP status code (200 or 503) corresponding to the verdict.</returns>
public static int HttpStatus(ReadinessVerdict verdict) => verdict switch
{
ReadinessVerdict.Healthy => 200,
@@ -22,6 +22,7 @@ public static class LogContextEnricher
/// <param name="driverType">The driver type name.</param>
/// <param name="capability">The driver capability being invoked.</param>
/// <param name="correlationId">The correlation ID for tracing the call.</param>
/// <returns>A scope that pops the pushed properties when disposed.</returns>
public static IDisposable Push(string driverInstanceId, string driverType, DriverCapability capability, string correlationId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
@@ -40,6 +41,7 @@ public static class LogContextEnricher
/// 12-hex-char slice of a GUID — long enough for log correlation, short enough to
/// scan visually.
/// </summary>
/// <returns>A 12-character hex string suitable for log correlation.</returns>
public static string NewCorrelationId() => Guid.NewGuid().ToString("N")[..12];
private sealed class CompositeScope : IDisposable
@@ -183,6 +183,7 @@ public static class EquipmentNodeWalker
/// wants an opaque non-JSON reference.
/// </remarks>
/// <param name="tagConfig">The tag configuration JSON or string.</param>
/// <returns>The value of the <c>FullName</c> field from the JSON, or the raw <paramref name="tagConfig"/> string as a fallback.</returns>
internal static string ExtractFullName(string tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
@@ -49,6 +49,7 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
/// </summary>
/// <param name="builder">The address space builder to populate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that represents the asynchronous address space build operation.</returns>
public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -111,23 +112,15 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
IAddressSpaceBuilder inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IAddressSpaceBuilder
{
/// <summary>Adds a folder to the address space.</summary>
/// <param name="browseName">The browse name of the folder node.</param>
/// <param name="displayName">The display name of the folder node.</param>
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
=> new CapturingBuilder(inner.Folder(browseName, displayName), sinks);
/// <summary>Adds a variable to the address space.</summary>
/// <param name="browseName">The browse name of the variable node.</param>
/// <param name="displayName">The display name of the variable node.</param>
/// <param name="attributeInfo">Metadata describing the variable's data type and properties.</param>
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> new CapturingHandle(inner.Variable(browseName, displayName, attributeInfo), sinks);
/// <summary>Adds a property to the address space.</summary>
/// <param name="browseName">The browse name of the property node.</param>
/// <param name="dataType">The OPC UA data type of the property.</param>
/// <param name="value">The initial value of the property, or null.</param>
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> inner.AddProperty(browseName, dataType, value);
}
@@ -136,11 +129,10 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable
IVariableHandle inner,
ConcurrentDictionary<string, IAlarmConditionSink> sinks) : IVariableHandle
{
/// <summary>Gets the full reference for the variable.</summary>
/// <inheritdoc />
public string FullReference => inner.FullReference;
/// <summary>Marks the variable as an alarm condition and registers its sink.</summary>
/// <param name="info">Configuration for the alarm condition.</param>
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
var sink = inner.MarkAsAlarmCondition(info);
@@ -59,6 +59,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="sourceNodeIds">The source node IDs to subscribe to.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that resolves to one subscription handle per resolved host.</returns>
public async Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(
IReadOnlyList<string> sourceNodeIds,
CancellationToken cancellationToken)
@@ -89,6 +90,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous unsubscribe operation.</returns>
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(handle);
@@ -110,6 +112,7 @@ public sealed class AlarmSurfaceInvoker
/// </summary>
/// <param name="acknowledgements">The alarm acknowledgement requests.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous acknowledgement operation.</returns>
public async Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
CancellationToken cancellationToken)
@@ -166,7 +169,7 @@ public sealed class AlarmSurfaceInvoker
public IAlarmSubscriptionHandle Inner { get; } = inner;
/// <summary>Gets the resolved host name.</summary>
public string Host { get; } = host;
/// <summary>Gets the diagnostic ID from the inner handle.</summary>
/// <inheritdoc />
public string DiagnosticId => Inner.DiagnosticId;
}
}
@@ -58,6 +58,7 @@ public sealed class CapabilityInvoker
/// <param name="hostName">The host name for logging and status tracking.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The result produced by <paramref name="callSite"/> after executing through the pipeline.</returns>
public async ValueTask<TResult> ExecuteAsync<TResult>(
DriverCapability capability,
string hostName,
@@ -86,6 +87,7 @@ public sealed class CapabilityInvoker
/// <param name="hostName">The host name for logging and status tracking.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A value task that represents the asynchronous operation.</returns>
public async ValueTask ExecuteAsync(
DriverCapability capability,
string hostName,
@@ -121,6 +123,7 @@ public sealed class CapabilityInvoker
/// <param name="isIdempotent">Whether the write operation is idempotent.</param>
/// <param name="callSite">The async function to execute.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The result produced by <paramref name="callSite"/> after executing through the write pipeline.</returns>
public async ValueTask<TResult> ExecuteWriteAsync<TResult>(
string hostName,
bool isIdempotent,
@@ -50,6 +50,7 @@ public static class DriverResilienceOptionsParser
/// <param name="tier">The driver tier for default resilience options.</param>
/// <param name="resilienceConfigJson">The optional JSON configuration string to parse.</param>
/// <param name="parseDiagnostic">An out parameter containing diagnostic information if parsing fails.</param>
/// <returns>The effective resilience options; tier defaults when the JSON is null or malformed.</returns>
public static DriverResilienceOptions ParseOrDefaults(
DriverTier tier,
string? resilienceConfigJson,
@@ -54,6 +54,7 @@ public sealed class DriverResiliencePipelineBuilder
/// </param>
/// <param name="capability">Which capability surface is being called.</param>
/// <param name="options">Per-driver-instance options (tier + per-capability overrides).</param>
/// <returns>The cached or newly created <see cref="ResiliencePipeline"/> for the given key.</returns>
public ResiliencePipeline GetOrCreate(
string driverInstanceId,
string hostName,
@@ -128,10 +128,12 @@ public sealed class DriverResilienceStatusTracker
/// <summary>Snapshot of a specific (instance, host) pair; null if no counters recorded yet.</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="hostName">The host name.</param>
/// <returns>The current <see cref="ResilienceStatusSnapshot"/> for the pair, or <see langword="null"/> if no counters have been recorded.</returns>
public ResilienceStatusSnapshot? TryGet(string driverInstanceId, string hostName) =>
_status.TryGetValue(new StatusKey(driverInstanceId, hostName), out var snapshot) ? snapshot : null;
/// <summary>Copy of every currently-tracked (instance, host, snapshot) triple. Safe under concurrent writes.</summary>
/// <returns>A snapshot list of all currently tracked driver instance and host resilience states.</returns>
public IReadOnlyList<(string DriverInstanceId, string HostName, ResilienceStatusSnapshot Snapshot)> Snapshot() =>
_status.Select(kvp => (kvp.Key.DriverInstanceId, kvp.Key.HostName, kvp.Value)).ToList();
@@ -33,6 +33,7 @@ public sealed class MemoryTracking
/// <summary>Tier-default multiplier/floor constants per decision #146.</summary>
/// <param name="tier">The driver tier.</param>
/// <returns>A tuple with the growth multiplier and the minimum floor bytes for the specified tier.</returns>
public static (int Multiplier, long FloorBytes) GetTierConstants(DriverTier tier) => tier switch
{
DriverTier.A => (Multiplier: 3, FloorBytes: 50L * 1024 * 1024),
@@ -73,6 +74,7 @@ public sealed class MemoryTracking
/// </summary>
/// <param name="footprintBytes">The current memory footprint in bytes.</param>
/// <param name="utcNow">The current UTC time.</param>
/// <returns>The <see cref="MemoryTrackingAction"/> classifying this sample against the soft/hard thresholds.</returns>
public MemoryTrackingAction Sample(long footprintBytes, DateTime utcNow)
{
if (_phase == TrackingPhase.WarmingUp)
@@ -60,6 +60,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// probe loop would race the operator's own reads.
/// </summary>
/// <param name="tags">The list of tag definitions to include in the options.</param>
/// <returns>A fully-configured <see cref="AbCipDriverOptions"/> with probe and alarm projection disabled.</returns>
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
{
Devices = [new AbCipDeviceOptions(
@@ -66,6 +66,7 @@ public sealed class ReadCommand : AbCipCommandBase
/// </summary>
/// <param name="tagPath">The symbolic tag path.</param>
/// <param name="type">The data type.</param>
/// <returns>A combined tag-name string in <c>path:type</c> form.</returns>
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
=> $"{tagPath}:{type}";
}
@@ -58,6 +58,7 @@ public sealed class ReadCommand : AbLegacyCommandBase
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
/// <param name="address">The PCCC file address.</param>
/// <param name="type">The data type of the address.</param>
/// <returns>A combined tag name string in the form <c>address:type</c>.</returns>
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
=> $"{address}:{type}";
}
@@ -23,6 +23,7 @@ public static class SnapshotFormatter
/// </summary>
/// <param name="tagName">The tag name to include in the output.</param>
/// <param name="snapshot">The data value snapshot to format.</param>
/// <returns>A multi-line string representation of the tag and its value.</returns>
public static string Format(string tagName, DataValueSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
@@ -42,6 +43,7 @@ public static class SnapshotFormatter
/// </summary>
/// <param name="tagName">The tag name to include in the output.</param>
/// <param name="result">The write result to format.</param>
/// <returns>A single-line string showing the tag name and write status.</returns>
public static string FormatWrite(string tagName, WriteResult result)
{
ArgumentNullException.ThrowIfNull(result);
@@ -54,6 +56,7 @@ public static class SnapshotFormatter
/// </summary>
/// <param name="tagNames">The list of tag names to include as rows.</param>
/// <param name="snapshots">The list of data value snapshots to format.</param>
/// <returns>An aligned table string with tag, value, status, and source-time columns.</returns>
public static string FormatTable(
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
{
@@ -52,6 +52,7 @@ public sealed class ReadCommand : FocasCommandBase
/// <summary>Constructs a tag name from address and data type.</summary>
/// <param name="address">The FOCAS address.</param>
/// <param name="type">The data type.</param>
/// <returns>A synthesized tag name string combining the address and data type.</returns>
internal static string SynthesiseTagName(string address, FocasDataType type)
=> $"{address}:{type}";
}
@@ -47,6 +47,7 @@ public abstract class FocasCommandBase : DriverCommandBase
/// as <c>BadCommunicationError</c>.
/// </summary>
/// <param name="tags">The tag definitions to include in the driver options.</param>
/// <returns>A <see cref="FocasDriverOptions"/> configured with the CNC target and the supplied tag list.</returns>
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
{
Devices = [new FocasDeviceOptions(
@@ -48,6 +48,7 @@ public abstract class ModbusCommandBase : DriverCommandBase
/// command against its own keep-alive reads.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>A <see cref="ModbusDriverOptions"/> configured for a one-shot CLI run.</returns>
protected ModbusDriverOptions BuildOptions(IReadOnlyList<ModbusTagDefinition> tags) => new()
{
Host = Host,
@@ -61,6 +61,7 @@ public sealed class ReadCommand : S7CommandBase
/// <summary>Tag-name key used internally. Address + type is already unique.</summary>
/// <param name="address">The S7 address to encode in the tag name.</param>
/// <param name="type">The data type to encode in the tag name.</param>
/// <returns>The synthesised tag name encoding the address and type.</returns>
internal static string SynthesiseTagName(string address, S7DataType type)
=> $"{address}:{type}";
}
@@ -51,6 +51,7 @@ public abstract class S7CommandBase : DriverCommandBase
/// disabled — CLI runs are one-shot.
/// </summary>
/// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>An <see cref="S7DriverOptions"/> populated with the current command-line values and the supplied tags.</returns>
protected S7DriverOptions BuildOptions(IReadOnlyList<S7TagDefinition> tags) => new()
{
Host = Host,
@@ -90,6 +90,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
/// </summary>
/// <param name="source">The source collection to filter.</param>
/// <param name="prefix">The prefix to filter on, or null to keep everything.</param>
/// <returns>A filtered list of variables whose browse names start with the given prefix.</returns>
internal static List<(string BrowseName, DriverAttributeInfo Info)> FilterByPrefix(
IReadOnlyList<(string BrowseName, DriverAttributeInfo Info)> source, string? prefix)
=> source
@@ -102,6 +103,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
/// </summary>
/// <param name="matchedCount">The number of matched items.</param>
/// <param name="max">The maximum number to show, or 0 for unbounded.</param>
/// <returns>The effective print limit: <paramref name="matchedCount"/> when unbounded, otherwise the lesser of <paramref name="max"/> and <paramref name="matchedCount"/>.</returns>
internal static int PrintLimit(int matchedCount, int max)
=> max <= 0 ? matchedCount : Math.Min(max, matchedCount);
@@ -112,6 +114,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
/// authorization is enforced server-side.
/// </summary>
/// <param name="info">The attribute info to label.</param>
/// <returns>"RO" for view-only attributes; "RW" for all others.</returns>
internal static string AccessTag(DriverAttributeInfo info)
=> info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
@@ -69,6 +69,7 @@ public sealed class WriteCommand : TwinCATTagCommandBase
/// <summary>Parse <c>--value</c> per <see cref="TwinCATDataType"/>, invariant culture.</summary>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="type">The target TwinCAT data type.</param>
/// <returns>The parsed value as a boxed .NET object matching the requested data type.</returns>
internal static object ParseValue(string raw, TwinCATDataType type) => type switch
{
TwinCATDataType.Bool => ParseBool(raw),
@@ -24,6 +24,7 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
/// native notifications toggled by <see cref="PollOnly"/>.
/// </summary>
/// <param name="tags">Tag definitions for the driver.</param>
/// <returns>A <see cref="TwinCATDriverOptions"/> configured for a single-device CLI run.</returns>
protected TwinCATDriverOptions BuildOptions(IReadOnlyList<TwinCATTagDefinition> tags) => new()
{
Devices = [new TwinCATDeviceOptions(
@@ -39,6 +40,7 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
// ---- Test hook ----
/// <summary>Test hook that exposes BuildOptions for unit testing.</summary>
/// <param name="tags">Tag definitions for the driver.</param>
/// <returns>A <see cref="TwinCATDriverOptions"/> configured for a single-device CLI run.</returns>
internal TwinCATDriverOptions BuildOptionsForTest(IReadOnlyList<TwinCATTagDefinition> tags)
=> BuildOptions(tags);
}
@@ -130,10 +130,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// </summary>
internal AbCipTemplateCache TemplateCache => _templateCache;
/// <summary>Gets the unique identifier for this driver instance.</summary>
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <summary>Gets the driver type identifier.</summary>
/// <inheritdoc />
public string DriverType => "AbCip";
/// <summary>
@@ -244,10 +244,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return Task.CompletedTask;
}
/// <summary>Reinitialize the driver by shutting down and reinitializing with new configuration.</summary>
/// <param name="driverConfigJson">The new driver configuration as JSON.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task representing the asynchronous reinitialization.</returns>
/// <inheritdoc />
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
@@ -305,19 +302,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- ISubscribable (polling overlay via shared engine) ----
/// <summary>Subscribe to value changes for the specified tag references.</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="publishingInterval">The interval at which to publish changes.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A handle representing the subscription.</returns>
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult(_poll.Subscribe(fullReferences, publishingInterval));
/// <summary>Unsubscribe from value changes using a subscription handle.</summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
@@ -349,19 +339,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
}
/// <summary>Unsubscribe from alarm events.</summary>
/// <param name="handle">The alarm subscription handle.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
: Task.CompletedTask;
/// <summary>Acknowledge alarms.</summary>
/// <param name="acknowledgements">The alarm acknowledgements to process.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
/// <inheritdoc />
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_options.EnableAlarmProjection
@@ -370,8 +354,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IHostConnectivityProbe ----
/// <summary>Gets the connectivity status of all configured devices.</summary>
/// <returns>A read-only list of host connectivity statuses.</returns>
/// <inheritdoc />
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
@@ -873,8 +856,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>Gets the current health status of the driver.</summary>
/// <returns>The driver health information.</returns>
/// <inheritdoc />
public DriverHealth GetHealth() => _health;
/// <summary>
@@ -885,9 +867,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <returns>The memory footprint in bytes.</returns>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches to free memory.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
_templateCache.Clear();
@@ -48,6 +48,7 @@ public static class AbCipStatusMapper
/// <summary>Map a CIP general-status byte to an OPC UA StatusCode.</summary>
/// <param name="status">The CIP general-status byte value.</param>
/// <returns>The corresponding OPC UA StatusCode.</returns>
public static uint MapCipGeneralStatus(byte status) => status switch
{
0x00 => Good,
@@ -72,6 +73,7 @@ public static class AbCipStatusMapper
/// operation; every other (negative) member is an error.
/// </summary>
/// <param name="status">The libplctag status code as an integer.</param>
/// <returns>The corresponding OPC UA StatusCode.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
/// <summary>
@@ -80,6 +82,7 @@ public static class AbCipStatusMapper
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
/// </summary>
/// <param name="status">The libplctag Status enum value.</param>
/// <returns>The corresponding OPC UA StatusCode.</returns>
public static uint MapLibplctagStatus(Status status) => status switch
{
Status.Ok => Good,
@@ -19,6 +19,7 @@ public static class AbCipSystemTagFilter
/// always preserved case and the system-tag prefixes are uppercase by convention.
/// </summary>
/// <param name="tagName">The tag name to check.</param>
/// <returns><see langword="true"/> if the tag is a system tag that should be hidden; otherwise <see langword="false"/>.</returns>
public static bool IsSystemTag(string tagName)
{
if (string.IsNullOrWhiteSpace(tagName)) return true;
@@ -24,6 +24,7 @@ public sealed record AbCipTagPath(
int? BitIndex)
{
/// <summary>Rebuild the canonical Logix tag string.</summary>
/// <returns>The canonical Logix tag string suitable for the libplctag <c>name=</c> attribute.</returns>
public string ToLibplctagName()
{
var buf = new System.Text.StringBuilder();
@@ -23,6 +23,7 @@ public sealed class AbCipTemplateCache
/// </summary>
/// <param name="deviceHostAddress">The device host address and port.</param>
/// <param name="templateInstanceId">The template instance ID.</param>
/// <returns>The cached <see cref="AbCipUdtShape"/>, or <c>null</c> if not yet populated.</returns>
public AbCipUdtShape? TryGet(string deviceHostAddress, uint templateInstanceId) =>
_shapes.TryGetValue((deviceHostAddress, templateInstanceId), out var shape) ? shape : null;
@@ -30,6 +30,7 @@ public static class AbCipUdtMemberLayout
/// if any member type is unsupported for declaration-only layout.
/// </summary>
/// <param name="members">The list of UDT member declarations.</param>
/// <returns>A dictionary mapping member name to byte offset, or <see langword="null"/> if any member type is unsupported.</returns>
public static IReadOnlyDictionary<string, int>? TryBuild(
IReadOnlyList<AbCipStructureMember> members)
{
@@ -28,6 +28,7 @@ public static class AbCipUdtReadPlanner
/// <param name="requests">The list of tag references to read.</param>
/// <param name="tagsByName">Dictionary mapping tag names to their definitions.</param>
/// <param name="enableDeclarationOnlyGrouping">Whether to enable UDT member grouping based on declaration order.</param>
/// <returns>An <see cref="AbCipUdtReadPlan"/> partitioning requests into whole-UDT groups and per-tag fallbacks.</returns>
public static AbCipUdtReadPlan Build(
IReadOnlyList<string> requests,
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
@@ -15,6 +15,7 @@ public interface IAbCipTagEnumerator : IDisposable
/// </summary>
/// <param name="deviceParams">Parameters for creating device tags.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of discovered tags.</returns>
IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
CancellationToken cancellationToken);
@@ -26,6 +27,7 @@ public interface IAbCipTagEnumeratorFactory
/// <summary>
/// Creates a new tag enumerator instance.
/// </summary>
/// <returns>A new <see cref="IAbCipTagEnumerator"/> instance.</returns>
IAbCipTagEnumerator Create();
}
@@ -59,6 +61,7 @@ internal sealed class EmptyAbCipTagEnumerator : IAbCipTagEnumerator
/// </summary>
/// <param name="deviceParams">Parameters for creating device tags.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An empty async enumerable of discovered tags.</returns>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -79,5 +82,6 @@ internal sealed class EmptyAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactor
/// <summary>
/// Creates a new empty tag enumerator.
/// </summary>
/// <returns>A new <see cref="EmptyAbCipTagEnumerator"/> instance.</returns>
public IAbCipTagEnumerator Create() => new EmptyAbCipTagEnumerator();
}
@@ -11,20 +11,24 @@ public interface IAbCipTagRuntime : IDisposable
{
/// <summary>Create the underlying native tag (equivalent to libplctag's <c>plc_tag_create</c>).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task InitializeAsync(CancellationToken cancellationToken);
/// <summary>Issue a read; on completion the local buffer holds the current PLC value.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task ReadAsync(CancellationToken cancellationToken);
/// <summary>Flush the local buffer to the PLC.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task WriteAsync(CancellationToken cancellationToken);
/// <summary>
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
/// </summary>
/// <returns>The raw libplctag status integer; zero on success, negative on error.</returns>
int GetStatus();
/// <summary>
@@ -34,6 +38,7 @@ public interface IAbCipTagRuntime : IDisposable
/// </summary>
/// <param name="type">CIP data type to decode.</param>
/// <param name="bitIndex">Bit index for BOOL-within-DINT extraction, or null.</param>
/// <returns>The decoded .NET value, or <c>null</c> if the buffer cannot be decoded for the given type.</returns>
object? DecodeValue(AbCipDataType type, int? bitIndex);
/// <summary>
@@ -48,6 +53,7 @@ public interface IAbCipTagRuntime : IDisposable
/// <param name="type">CIP data type to decode.</param>
/// <param name="offset">Byte offset in the buffer.</param>
/// <param name="bitIndex">Bit index for BOOL-within-DINT extraction, or null.</param>
/// <returns>The decoded .NET value at the specified offset, or <c>null</c> if the offset is unsupported.</returns>
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
/// <summary>
@@ -68,6 +74,7 @@ public interface IAbCipTagFactory
{
/// <summary>Creates a tag runtime handle from the specified creation parameters.</summary>
/// <param name="createParams">Parameters needed to create the tag runtime.</param>
/// <returns>A new <see cref="IAbCipTagRuntime"/> instance for the specified tag.</returns>
IAbCipTagRuntime Create(AbCipTagCreateParams createParams);
}

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