docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
This commit is contained in:
@@ -4,8 +4,13 @@ public sealed class AkkaClusterOptions
|
||||
{
|
||||
public const string SectionName = "Cluster";
|
||||
|
||||
/// <summary>Gets or sets the Akka system name.</summary>
|
||||
public string SystemName { get; set; } = "otopcua";
|
||||
|
||||
/// <summary>Gets or sets the hostname to bind to (default 0.0.0.0).</summary>
|
||||
public string Hostname { get; set; } = "0.0.0.0";
|
||||
|
||||
/// <summary>Gets or sets the port to listen on (default 4053).</summary>
|
||||
public int Port { get; set; } = 4053;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,6 +20,7 @@ public sealed class AkkaClusterOptions
|
||||
/// </summary>
|
||||
public string PublicHostname { get; set; } = "127.0.0.1";
|
||||
|
||||
/// <summary>Gets or sets the seed nodes for cluster bootstrapping.</summary>
|
||||
public string[] SeedNodes { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -25,6 +25,10 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.Ordinal);
|
||||
private IActorRef? _subscriber;
|
||||
|
||||
/// <summary>Initializes a new instance of the ClusterRoleInfo class.</summary>
|
||||
/// <param name="system">The Akka actor system.</param>
|
||||
/// <param name="options">The cluster configuration options.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public ClusterRoleInfo(ActorSystem system, IOptions<AkkaClusterOptions> options, ILogger<ClusterRoleInfo> logger)
|
||||
{
|
||||
_cluster = Akka.Cluster.Cluster.Get(system);
|
||||
@@ -39,12 +43,20 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
|
||||
}
|
||||
|
||||
/// <summary>Gets the local cluster node identifier.</summary>
|
||||
public CommonsNodeId LocalNode => _localNode;
|
||||
|
||||
/// <summary>Gets the set of roles assigned to the local node.</summary>
|
||||
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>
|
||||
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>
|
||||
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -56,6 +68,9 @@ 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>
|
||||
public CommonsNodeId? RoleLeader(string role)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -66,6 +81,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Occurs when the leader for a role changes.</summary>
|
||||
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
|
||||
|
||||
private void SeedFromCurrentState()
|
||||
@@ -91,6 +107,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handles a cluster member event (member up/removed).</summary>
|
||||
/// <param name="evt">The member event from the cluster.</param>
|
||||
internal void HandleMemberEvent(ClusterEvent.IMemberEvent evt)
|
||||
{
|
||||
lock (_lock)
|
||||
@@ -114,6 +132,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handles a role leader change event.</summary>
|
||||
/// <param name="evt">The role leader changed event from the cluster.</param>
|
||||
internal void HandleRoleLeaderChanged(ClusterEvent.RoleLeaderChanged evt)
|
||||
{
|
||||
CommonsNodeId? previous = null;
|
||||
@@ -156,6 +176,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
private static CommonsNodeId ToNodeId(Akka.Actor.Address address) =>
|
||||
CommonsNodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}");
|
||||
|
||||
/// <summary>Disposes the ClusterRoleInfo and stops the subscriber actor.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_subscriber?.Tell(PoisonPill.Instance);
|
||||
@@ -164,6 +185,8 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
|
||||
private sealed class SubscriberActor : ReceiveActor
|
||||
{
|
||||
/// <summary>Initializes a new instance of the SubscriberActor class.</summary>
|
||||
/// <param name="owner">The ClusterRoleInfo instance to forward events to.</param>
|
||||
public SubscriberActor(ClusterRoleInfo owner)
|
||||
{
|
||||
Receive<ClusterEvent.IMemberEvent>(e => owner.HandleMemberEvent(e));
|
||||
@@ -172,6 +195,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
Receive<ClusterEvent.CurrentClusterState>(_ => { /* seeded from initial snapshot */ });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
Akka.Cluster.Cluster.Get(Context.System).Subscribe(
|
||||
@@ -182,6 +206,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
typeof(ClusterEvent.RoleLeaderChanged));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop() =>
|
||||
Akka.Cluster.Cluster.Get(Context.System).Unsubscribe(Self);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
/// <summary>Loads embedded HOCON configuration resources.</summary>
|
||||
public static class HoconLoader
|
||||
{
|
||||
private const string ResourceName = "ZB.MOM.WW.OtOpcUa.Cluster.Resources.akka.conf";
|
||||
|
||||
/// <summary>Loads the base Akka configuration from embedded resources.</summary>
|
||||
/// <returns>The loaded HOCON configuration as a string.</returns>
|
||||
public static string LoadBaseConfig()
|
||||
{
|
||||
using var stream = typeof(HoconLoader).Assembly.GetManifestResourceStream(ResourceName)
|
||||
|
||||
@@ -7,6 +7,8 @@ public static class RoleParser
|
||||
"admin", "driver", "dev",
|
||||
};
|
||||
|
||||
/// <summary>Parses a comma-separated string of role names into a validated array.</summary>
|
||||
/// <param name="raw">The raw role string to parse.</param>
|
||||
public static string[] Parse(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return Array.Empty<string>();
|
||||
|
||||
@@ -16,6 +16,8 @@ public static class ServiceCollectionExtensions
|
||||
/// configurator via <see cref="WithOtOpcUaClusterBootstrap"/> — keeping the entire Akka graph
|
||||
/// under Akka.Hosting's management so cluster singletons land on the same ActorSystem.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="configuration">The application configuration containing cluster options.</param>
|
||||
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<AkkaClusterOptions>()
|
||||
@@ -41,6 +43,8 @@ public static class ServiceCollectionExtensions
|
||||
/// });
|
||||
/// </code>
|
||||
/// </summary>
|
||||
/// <param name="builder">The Akka configuration builder to configure.</param>
|
||||
/// <param name="serviceProvider">The service provider for resolving cluster options.</param>
|
||||
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
|
||||
this AkkaConfigurationBuilder builder,
|
||||
IServiceProvider serviceProvider)
|
||||
|
||||
@@ -10,7 +10,14 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
/// </summary>
|
||||
public interface IAlarmActorStateStore
|
||||
{
|
||||
/// <summary>Loads the persisted state snapshot for an alarm actor.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The alarm state snapshot if found; null if the alarm has no persisted state.</returns>
|
||||
Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct);
|
||||
/// <summary>Saves the alarm actor state snapshot.</summary>
|
||||
/// <param name="snapshot">The state snapshot to persist.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -34,8 +41,14 @@ 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>
|
||||
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>
|
||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
/// </summary>
|
||||
public interface IScriptedAlarmEvaluator
|
||||
{
|
||||
/// <summary>Evaluates an alarm predicate against the provided dependencies.</summary>
|
||||
/// <param name="alarmId">The unique identifier of the alarm being evaluated.</param>
|
||||
/// <param name="predicate">The predicate expression to evaluate.</param>
|
||||
/// <param name="dependencies">Read-only dictionary of variable names to values for predicate evaluation.</param>
|
||||
/// <returns>Result containing success flag, alarm active state, and optional failure reason.</returns>
|
||||
ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies);
|
||||
}
|
||||
|
||||
@@ -15,7 +20,14 @@ public interface IScriptedAlarmEvaluator
|
||||
/// <c>Success</c> is true; on failure the caller should keep the prior state and log Reason.</summary>
|
||||
public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason)
|
||||
{
|
||||
/// <summary>Creates a successful alarm evaluation result with the given active state.</summary>
|
||||
/// <param name="active">Whether the alarm condition is active.</param>
|
||||
/// <returns>A successful evaluation result.</returns>
|
||||
public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null);
|
||||
|
||||
/// <summary>Creates a failed alarm evaluation result with the given reason.</summary>
|
||||
/// <param name="reason">Description of the evaluation failure cause.</param>
|
||||
/// <returns>A failed evaluation result.</returns>
|
||||
public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason);
|
||||
}
|
||||
|
||||
@@ -25,6 +37,11 @@ public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator
|
||||
{
|
||||
public static readonly NullScriptedAlarmEvaluator Instance = new();
|
||||
private NullScriptedAlarmEvaluator() { }
|
||||
/// <summary>Returns an inactive alarm result for every evaluation (safe no-op behavior).</summary>
|
||||
/// <param name="alarmId">The alarm identifier (ignored).</param>
|
||||
/// <param name="predicate">The predicate expression (ignored).</param>
|
||||
/// <param name="dependencies">The variable dependencies (ignored).</param>
|
||||
/// <returns>Always returns an inactive alarm result.</returns>
|
||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> ScriptedAlarmEvalResult.Ok(active: false);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ public interface IVirtualTagEvaluator
|
||||
/// <paramref name="dependencies"/>. Implementations must not throw — script failures
|
||||
/// are reported via <see cref="VirtualTagEvalResult.Failure"/>.
|
||||
/// </summary>
|
||||
/// <param name="virtualTagId">The unique identifier of the virtual tag being evaluated.</param>
|
||||
/// <param name="expression">The expression string to evaluate.</param>
|
||||
/// <param name="dependencies">Read-only dictionary of variable names to values for expression evaluation.</param>
|
||||
/// <returns>Result containing success flag, evaluated value, and optional failure reason.</returns>
|
||||
VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies);
|
||||
}
|
||||
|
||||
@@ -21,7 +25,15 @@ public interface IVirtualTagEvaluator
|
||||
public sealed record VirtualTagEvalResult(bool Success, object? Value, string? Reason)
|
||||
{
|
||||
public static readonly VirtualTagEvalResult NoChange = new(true, null, "no-change");
|
||||
|
||||
/// <summary>Creates a successful evaluation result with the given value.</summary>
|
||||
/// <param name="value">The evaluated value.</param>
|
||||
/// <returns>A successful evaluation result.</returns>
|
||||
public static VirtualTagEvalResult Ok(object? value) => new(true, value, null);
|
||||
|
||||
/// <summary>Creates a failed evaluation result with the given reason.</summary>
|
||||
/// <param name="reason">Description of the failure cause.</param>
|
||||
/// <returns>A failed evaluation result.</returns>
|
||||
public static VirtualTagEvalResult Failure(string reason) => new(false, null, reason);
|
||||
}
|
||||
|
||||
@@ -31,6 +43,11 @@ 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>
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> VirtualTagEvalResult.NoChange;
|
||||
}
|
||||
|
||||
@@ -9,5 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
/// </summary>
|
||||
public interface IAdminOperationsClient
|
||||
{
|
||||
/// <summary>Starts a new deployment on the cluster-singleton admin operations actor.</summary>
|
||||
/// <param name="createdBy">The user or system identifier triggering the deployment.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation containing the deployment start result.</returns>
|
||||
Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
/// </summary>
|
||||
public interface IClusterRoleInfo
|
||||
{
|
||||
/// <summary>Gets the local cluster node identifier.</summary>
|
||||
NodeId LocalNode { get; }
|
||||
/// <summary>Gets the set of roles assigned to the local node.</summary>
|
||||
IReadOnlySet<string> LocalRoles { get; }
|
||||
/// <summary>Checks if the local node has the specified role.</summary>
|
||||
/// <param name="role">Role name to check.</param>
|
||||
/// <returns>True if the local node has the role; otherwise, false.</returns>
|
||||
bool HasRole(string role);
|
||||
/// <summary>Gets all nodes assigned to the specified role.</summary>
|
||||
/// <param name="role">Role name to query.</param>
|
||||
/// <returns>List of node identifiers with the role.</returns>
|
||||
IReadOnlyList<NodeId> MembersWithRole(string role);
|
||||
/// <summary>Gets the leader node for the specified role, or null if no leader is elected.</summary>
|
||||
/// <param name="role">Role name to query.</param>
|
||||
/// <returns>The leader node identifier, or null if no leader exists.</returns>
|
||||
NodeId? RoleLeader(string role);
|
||||
|
||||
/// <summary>Occurs when the leader of a role changes.</summary>
|
||||
event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
|
||||
}
|
||||
|
||||
@@ -8,5 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
/// </summary>
|
||||
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>
|
||||
Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
|
||||
/// <summary>Event arguments for role leader change notifications.</summary>
|
||||
public sealed class RoleLeaderChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>Gets the role name that changed leadership.</summary>
|
||||
public required string Role { get; init; }
|
||||
|
||||
/// <summary>Gets the previous leader node ID, or null if there was no previous leader.</summary>
|
||||
public required NodeId? PreviousLeader { get; init; }
|
||||
|
||||
/// <summary>Gets the new leader node ID, or null if the role is now leaderless.</summary>
|
||||
public required NodeId? NewLeader { get; init; }
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ public static class OtOpcUaTelemetry
|
||||
/// Starts a deploy span tagged with the deployment id. Caller disposes to close. Returns
|
||||
/// 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>
|
||||
public static Activity? StartDeployApplySpan(string deploymentId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
|
||||
|
||||
@@ -18,20 +18,41 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
|
||||
/// <summary>Swap in the production sink. Pass <c>null</c> to revert to the null sink
|
||||
/// (used during graceful shutdown so post-stop writes don't hit a half-disposed manager).</summary>
|
||||
/// <param name="sink">The sink implementation to use, or null to use the null sink.</param>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
||||
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
|
||||
|
||||
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
|
||||
/// <param name="inner">The publisher implementation to use, or null to use the null publisher.</param>
|
||||
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>
|
||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,17 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
public interface IOpcUaAddressSpaceSink
|
||||
{
|
||||
/// <summary>Write a Variable node's current value + quality + source timestamp.</summary>
|
||||
/// <param name="nodeId">The OPC UA node ID of the variable.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <param name="quality">The quality status of the value.</param>
|
||||
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
|
||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
||||
/// <param name="alarmNodeId">The OPC UA node ID of the alarm.</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>
|
||||
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>
|
||||
@@ -20,6 +28,9 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// <paramref name="parentNodeId"/> is null the folder is parented under the namespace
|
||||
/// root. Idempotent: calling twice with the same id is safe.
|
||||
/// </summary>
|
||||
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
|
||||
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
|
||||
/// <param name="displayName">The display name for the folder.</param>
|
||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +40,9 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// Used by <c>Phase7Applier</c> to materialise Galaxy / SystemPlatform tags ahead of any
|
||||
/// driver-side subscribe so OPC UA clients can browse them. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="variableNodeId">The OPC UA node ID for the variable.</param>
|
||||
/// <param name="parentFolderNodeId">The parent folder node ID, or null for namespace root.</param>
|
||||
/// <param name="displayName">The display name for the variable.</param>
|
||||
/// <param name="dataType">OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).</param>
|
||||
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType);
|
||||
|
||||
@@ -49,9 +63,19 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
public static readonly NullOpcUaAddressSpaceSink Instance = new();
|
||||
private NullOpcUaAddressSpaceSink() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() { }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
/// </summary>
|
||||
public interface IServiceLevelPublisher
|
||||
{
|
||||
/// <summary>Publishes the service level value to the OPC UA Server object.</summary>
|
||||
/// <param name="serviceLevel">The service level value (0-255).</param>
|
||||
void Publish(byte serviceLevel);
|
||||
}
|
||||
|
||||
@@ -17,6 +19,11 @@ public sealed class NullServiceLevelPublisher : IServiceLevelPublisher
|
||||
{
|
||||
public static readonly NullServiceLevelPublisher Instance = new();
|
||||
private NullServiceLevelPublisher() { }
|
||||
|
||||
/// <summary>Gets the last published service level value.</summary>
|
||||
public byte LastPublished { get; private set; }
|
||||
|
||||
/// <summary>Records the service level value without publishing.</summary>
|
||||
/// <param name="serviceLevel">The service level value (0-255).</param>
|
||||
public void Publish(byte serviceLevel) => LastPublished = serviceLevel;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@ 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>
|
||||
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>
|
||||
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>
|
||||
public static bool TryParse(string? s, out CorrelationId id)
|
||||
{
|
||||
if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; }
|
||||
|
||||
@@ -2,9 +2,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
public readonly record struct DeploymentId(Guid Value)
|
||||
{
|
||||
/// <summary>Creates a new deployment ID with a random GUID.</summary>
|
||||
/// <returns>A new DeploymentId.</returns>
|
||||
public static DeploymentId NewId() => new(Guid.NewGuid());
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value.ToString("N");
|
||||
|
||||
/// <summary>Parses a deployment ID from a hex string without hyphens.</summary>
|
||||
/// <param name="s">The hex string to parse.</param>
|
||||
/// <returns>The parsed DeploymentId.</returns>
|
||||
public static DeploymentId Parse(string s) => new(Guid.ParseExact(s, "N"));
|
||||
|
||||
/// <summary>Attempts to parse a deployment ID from a hex string without hyphens.</summary>
|
||||
/// <param name="s">The hex string to parse, or null.</param>
|
||||
/// <param name="id">The parsed DeploymentId if successful, or default.</param>
|
||||
/// <returns>True if parsing succeeded; false otherwise.</returns>
|
||||
public static bool TryParse(string? s, out DeploymentId id)
|
||||
{
|
||||
if (Guid.TryParseExact(s, "N", out var g)) { id = new DeploymentId(g); return true; }
|
||||
|
||||
@@ -2,9 +2,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
public readonly record struct ExecutionId(Guid Value)
|
||||
{
|
||||
/// <summary>Creates a new execution ID with a randomly generated GUID.</summary>
|
||||
/// <returns>A new ExecutionId instance.</returns>
|
||||
public static ExecutionId NewId() => new(Guid.NewGuid());
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value.ToString("N");
|
||||
|
||||
/// <summary>Parses the specified string into an ExecutionId in format N.</summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <returns>The parsed ExecutionId.</returns>
|
||||
public static ExecutionId Parse(string s) => new(Guid.ParseExact(s, "N"));
|
||||
|
||||
/// <summary>Tries to parse the specified string into an ExecutionId in format N.</summary>
|
||||
/// <param name="s">The string to parse, or null.</param>
|
||||
/// <param name="id">The parsed ExecutionId, or default if parsing fails.</param>
|
||||
/// <returns>true if parsing succeeded; otherwise, false.</returns>
|
||||
public static bool TryParse(string? s, out ExecutionId id)
|
||||
{
|
||||
if (Guid.TryParseExact(s, "N", out var g)) { id = new ExecutionId(g); return true; }
|
||||
|
||||
@@ -7,11 +7,22 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
/// </summary>
|
||||
public readonly record struct NodeId(string Value)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value;
|
||||
|
||||
/// <summary>Parses a string into a NodeId.</summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <returns>A new NodeId instance.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the string is null, empty, or whitespace.</exception>
|
||||
public static NodeId Parse(string s) =>
|
||||
string.IsNullOrWhiteSpace(s)
|
||||
? throw new ArgumentException("NodeId value cannot be empty.", nameof(s))
|
||||
: new NodeId(s);
|
||||
|
||||
/// <summary>Attempts to parse a string into a NodeId.</summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <param name="id">The parsed NodeId if successful.</param>
|
||||
/// <returns>True if the parse succeeded; otherwise false.</returns>
|
||||
public static bool TryParse(string? s, out NodeId id)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s)) { id = new NodeId(s); return true; }
|
||||
|
||||
@@ -6,11 +6,24 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
/// </summary>
|
||||
public readonly record struct RevisionHash(string Value)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Value;
|
||||
/// <summary>
|
||||
/// Parses a string into a <see cref="RevisionHash"/>.
|
||||
/// </summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <returns>A <see cref="RevisionHash"/> instance.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if the input is null, empty, or whitespace.</exception>
|
||||
public static RevisionHash Parse(string s) =>
|
||||
string.IsNullOrWhiteSpace(s)
|
||||
? throw new ArgumentException("RevisionHash value cannot be empty.", nameof(s))
|
||||
: new RevisionHash(s);
|
||||
/// <summary>
|
||||
/// Attempts to parse a string into a <see cref="RevisionHash"/>.
|
||||
/// </summary>
|
||||
/// <param name="s">The string to parse.</param>
|
||||
/// <param name="hash">The parsed hash, or default if parsing fails.</param>
|
||||
/// <returns>True if parsing succeeded; otherwise false.</returns>
|
||||
public static bool TryParse(string? s, out RevisionHash hash)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s)) { hash = new RevisionHash(s); return true; }
|
||||
|
||||
@@ -17,6 +17,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
/// </remarks>
|
||||
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
/// <summary>Creates a new DbContext instance for design-time operations.</summary>
|
||||
/// <param name="args">Command-line arguments (unused).</param>
|
||||
/// <returns>The configured DbContext instance.</returns>
|
||||
public OtOpcUaConfigDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connection = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_CONNECTION");
|
||||
|
||||
@@ -6,13 +6,16 @@ public sealed class ClusterNode
|
||||
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
/// <summary>The unique identifier of the cluster this node belongs to.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>Machine hostname / IP.</summary>
|
||||
public required string Host { get; set; }
|
||||
|
||||
/// <summary>The OPC UA server port (default 4840).</summary>
|
||||
public int OpcUaPort { get; set; } = 4840;
|
||||
|
||||
/// <summary>The dashboard HTTP port (default 8081).</summary>
|
||||
public int DashboardPort { get; set; } = 8081;
|
||||
|
||||
/// <summary>
|
||||
@@ -32,15 +35,21 @@ public sealed class ClusterNode
|
||||
/// </summary>
|
||||
public string? DriverConfigOverridesJson { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this node is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Gets or sets the timestamp when this node was last seen.</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the timestamp when this node was created.</summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets or sets the username of who created this node.</summary>
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
/// <summary>Gets or sets the cluster this node belongs to.</summary>
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
/// <summary>Gets or sets the credentials associated with this node.</summary>
|
||||
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -8,22 +8,30 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeCredential
|
||||
{
|
||||
/// <summary>Gets or sets the credential identifier.</summary>
|
||||
public Guid CredentialId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the node identifier this credential binds to.</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the credential kind (login, certificate, etc.).</summary>
|
||||
public required CredentialKind Kind { get; set; }
|
||||
|
||||
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
|
||||
/// <summary>Gets or sets the credential value (login name / cert thumbprint / SID / gMSA name).</summary>
|
||||
public required string Value { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the credential is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Gets or sets the date/time when the credential was last rotated.</summary>
|
||||
public DateTime? RotatedAt { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the date/time when the credential was created.</summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets or sets the user who created the credential.</summary>
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the related cluster node.</summary>
|
||||
public ClusterNode? Node { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,21 +6,28 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class ConfigAuditLog
|
||||
{
|
||||
/// <summary>Gets or sets the unique audit log identifier.</summary>
|
||||
public long AuditId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the timestamp of the audit event.</summary>
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets or sets the principal (user or service) that initiated the event.</summary>
|
||||
public required string Principal { get; set; }
|
||||
|
||||
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
|
||||
public required string EventType { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the cluster identifier associated with the event, if applicable.</summary>
|
||||
public string? ClusterId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the node identifier associated with the event, if applicable.</summary>
|
||||
public string? NodeId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the generation identifier associated with the event, if applicable.</summary>
|
||||
public long? GenerationId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets additional event details in JSON format.</summary>
|
||||
public string? DetailsJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,21 +7,27 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class ConfigEdit
|
||||
{
|
||||
/// <summary>Gets the unique identifier for this edit.</summary>
|
||||
public Guid EditId { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Gets the type of entity that was edited.</summary>
|
||||
public required string EntityType { get; init; }
|
||||
|
||||
/// <summary>Gets the identifier of the entity that was edited.</summary>
|
||||
public Guid EntityId { get; init; }
|
||||
|
||||
/// <summary>JSON payload of the column-name → new-value pairs touched by this edit.</summary>
|
||||
/// <summary>Gets the JSON payload of the column-name → new-value pairs touched by this edit.</summary>
|
||||
public required string FieldsJson { get; init; }
|
||||
|
||||
/// <summary>Optional correlation across edits inside a single admin operation.</summary>
|
||||
/// <summary>Gets the optional correlation identifier across edits inside a single admin operation.</summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>Gets the username of the user who performed the edit.</summary>
|
||||
public required string EditedBy { get; init; }
|
||||
|
||||
/// <summary>Gets the UTC timestamp when the edit was performed.</summary>
|
||||
public DateTime EditedAtUtc { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets the node identifier of the admin instance that performed the edit.</summary>
|
||||
public required string SourceNode { get; init; }
|
||||
}
|
||||
|
||||
@@ -10,21 +10,30 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class Deployment
|
||||
{
|
||||
/// <summary>Gets or sets the unique deployment identifier.</summary>
|
||||
public Guid DeploymentId { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Gets or sets the revision hash of the deployment artifact.</summary>
|
||||
public required string RevisionHash { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the deployment status.</summary>
|
||||
public DeploymentStatus Status { get; set; } = DeploymentStatus.Dispatching;
|
||||
|
||||
/// <summary>Gets or sets the name of the user who created the deployment.</summary>
|
||||
public required string CreatedBy { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when the deployment was created.</summary>
|
||||
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets or sets the serialized artifact blob containing the configuration.</summary>
|
||||
public byte[] ArtifactBlob { get; init; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Gets or sets the row version for optimistic concurrency control.</summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Gets or sets the failure reason if the deployment failed.</summary>
|
||||
public string? FailureReason { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when the deployment was sealed.</summary>
|
||||
public DateTime? SealedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,15 +3,27 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
|
||||
public sealed class Device
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique database row identifier for the device.
|
||||
/// </summary>
|
||||
public Guid DeviceRowId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device identifier.
|
||||
/// </summary>
|
||||
public required string DeviceId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device name.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the device is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
|
||||
|
||||
@@ -39,6 +39,7 @@ public sealed class DriverHostStatus
|
||||
/// </summary>
|
||||
public required string HostName { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the current connectivity state of the host.</summary>
|
||||
public DriverHostState State { get; set; } = DriverHostState.Unknown;
|
||||
|
||||
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
|
||||
|
||||
@@ -3,10 +3,13 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
|
||||
public sealed class DriverInstance
|
||||
{
|
||||
/// <summary>Gets or sets the row ID for this driver instance.</summary>
|
||||
public Guid DriverInstanceRowId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the unique driver instance identifier.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the cluster ID this driver instance belongs to.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -15,11 +18,13 @@ public sealed class DriverInstance
|
||||
/// </summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the friendly name of this driver instance.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
|
||||
public required string DriverType { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this driver instance is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||
@@ -46,5 +51,6 @@ public sealed class DriverInstance
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Gets or sets the related server cluster for navigation.</summary>
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </remarks>
|
||||
public sealed class DriverInstanceResilienceStatus
|
||||
{
|
||||
/// <summary>Gets or sets the driver instance identifier.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
/// <summary>Gets or sets the host name.</summary>
|
||||
public required string HostName { get; set; }
|
||||
|
||||
/// <summary>Most recent time the circuit breaker for this (instance, host) opened; null if never.</summary>
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class Equipment
|
||||
{
|
||||
/// <summary>Gets or sets the row identifier for this equipment.</summary>
|
||||
public Guid EquipmentRowId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -43,19 +44,29 @@ public sealed class Equipment
|
||||
|
||||
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
|
||||
// All nullable so equipment can be added before identity is fully captured.
|
||||
/// <summary>Gets or sets the manufacturer name for this equipment.</summary>
|
||||
public string? Manufacturer { get; set; }
|
||||
/// <summary>Gets or sets the model number or designation for this equipment.</summary>
|
||||
public string? Model { get; set; }
|
||||
/// <summary>Gets or sets the serial number for this equipment.</summary>
|
||||
public string? SerialNumber { get; set; }
|
||||
/// <summary>Gets or sets the hardware revision level for this equipment.</summary>
|
||||
public string? HardwareRevision { get; set; }
|
||||
/// <summary>Gets or sets the software revision level for this equipment.</summary>
|
||||
public string? SoftwareRevision { get; set; }
|
||||
/// <summary>Gets or sets the year of construction for this equipment.</summary>
|
||||
public short? YearOfConstruction { get; set; }
|
||||
/// <summary>Gets or sets the asset location information for this equipment.</summary>
|
||||
public string? AssetLocation { get; set; }
|
||||
/// <summary>Gets or sets the manufacturer URI for this equipment.</summary>
|
||||
public string? ManufacturerUri { get; set; }
|
||||
/// <summary>Gets or sets the device manual URI for this equipment.</summary>
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
|
||||
public string? EquipmentClassRef { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether this equipment is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
|
||||
@@ -17,15 +17,31 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </remarks>
|
||||
public sealed class EquipmentImportBatch
|
||||
{
|
||||
/// <summary>Gets or sets the unique identifier for this batch.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the cluster identifier.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the user name who created this batch.</summary>
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when this batch was created.</summary>
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the total number of rows staged in this batch.</summary>
|
||||
public int RowsStaged { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the number of rows accepted in this batch.</summary>
|
||||
public int RowsAccepted { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the number of rows rejected in this batch.</summary>
|
||||
public int RowsRejected { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when this batch was finalised, or null if still in staging.</summary>
|
||||
public DateTime? FinalisedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the collection of staged rows in this batch.</summary>
|
||||
public ICollection<EquipmentImportRow> Rows { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -37,32 +53,74 @@ public sealed class EquipmentImportBatch
|
||||
/// </summary>
|
||||
public sealed class EquipmentImportRow
|
||||
{
|
||||
/// <summary>Gets or sets the unique identifier for this row.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the parent batch identifier.</summary>
|
||||
public Guid BatchId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the line number in the source file.</summary>
|
||||
public int LineNumberInFile { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this row was accepted.</summary>
|
||||
public bool IsAccepted { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the reason this row was rejected, if applicable.</summary>
|
||||
public string? RejectReason { get; set; }
|
||||
|
||||
// Required (decision #117)
|
||||
/// <summary>Gets or sets the Z tag identifier.</summary>
|
||||
public required string ZTag { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the machine code.</summary>
|
||||
public required string MachineCode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the SAP identifier.</summary>
|
||||
public required string SAPID { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the equipment identifier.</summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the equipment UUID.</summary>
|
||||
public required string EquipmentUuid { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the equipment name.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UNS area name.</summary>
|
||||
public required string UnsAreaName { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UNS line name.</summary>
|
||||
public required string UnsLineName { get; set; }
|
||||
|
||||
// Optional (decision #139 — OPC 40010 Identification)
|
||||
/// <summary>Gets or sets the manufacturer name.</summary>
|
||||
public string? Manufacturer { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the equipment model.</summary>
|
||||
public string? Model { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the serial number.</summary>
|
||||
public string? SerialNumber { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the hardware revision.</summary>
|
||||
public string? HardwareRevision { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the software revision.</summary>
|
||||
public string? SoftwareRevision { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the year of construction.</summary>
|
||||
public string? YearOfConstruction { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the asset location.</summary>
|
||||
public string? AssetLocation { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the manufacturer URI.</summary>
|
||||
public string? ManufacturerUri { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the device manual URI.</summary>
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the parent batch.</summary>
|
||||
public EquipmentImportBatch? Batch { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservation
|
||||
{
|
||||
/// <summary>Gets or sets the unique reservation identifier.</summary>
|
||||
public Guid ReservationId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the kind of reservation (ZTag or SAPID).</summary>
|
||||
public required ReservationKind Kind { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the reserved external ID value.</summary>
|
||||
public required string Value { get; set; }
|
||||
|
||||
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
|
||||
@@ -21,16 +24,21 @@ public sealed class ExternalIdReservation
|
||||
/// <summary>First cluster to publish this reservation.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the timestamp when the reservation was first published.</summary>
|
||||
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets or sets the identifier of the user or system that first published the reservation.</summary>
|
||||
public required string FirstPublishedBy { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the timestamp of the most recent publication.</summary>
|
||||
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
|
||||
public DateTime? ReleasedAt { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the identifier of the user or system that released the reservation.</summary>
|
||||
public string? ReleasedBy { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the reason for releasing the reservation.</summary>
|
||||
public string? ReleaseReason { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,24 +8,30 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class Namespace
|
||||
{
|
||||
/// <summary>Gets or sets the row identifier for this namespace.</summary>
|
||||
public Guid NamespaceRowId { get; set; }
|
||||
|
||||
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA-equipment". Globally unique in v2.</summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the cluster identifier.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the namespace kind.</summary>
|
||||
public required NamespaceKind Kind { get; set; }
|
||||
|
||||
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
|
||||
public required string NamespaceUri { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the namespace is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Gets or sets optional notes about the namespace.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Gets or sets the associated server cluster.</summary>
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,14 +8,19 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class NodeAcl
|
||||
{
|
||||
/// <summary>Gets or sets the database row ID for this ACL entry.</summary>
|
||||
public Guid NodeAclRowId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the logical ID of this ACL entry.</summary>
|
||||
public required string NodeAclId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the cluster ID for this ACL entry.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the LDAP group for this ACL entry.</summary>
|
||||
public required string LdapGroup { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the scope kind for this ACL entry.</summary>
|
||||
public required NodeAclScopeKind ScopeKind { get; set; }
|
||||
|
||||
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
|
||||
@@ -24,6 +29,7 @@ public sealed class NodeAcl
|
||||
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
|
||||
public required NodePermissions PermissionFlags { get; set; }
|
||||
|
||||
/// <summary>Gets or sets optional notes for this ACL entry.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
|
||||
@@ -10,20 +10,29 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class NodeDeploymentState
|
||||
{
|
||||
/// <summary>Gets or sets the cluster node identifier.</summary>
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the deployment identifier.</summary>
|
||||
public Guid DeploymentId { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the deployment status on this node.</summary>
|
||||
public NodeDeploymentStatus Status { get; set; } = NodeDeploymentStatus.Applying;
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when the deployment application started.</summary>
|
||||
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when the deployment was successfully applied, or null if not yet applied.</summary>
|
||||
public DateTime? AppliedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the failure reason if the deployment failed, or null if successful.</summary>
|
||||
public string? FailureReason { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the row version for optimistic concurrency control.</summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Gets or sets the cluster node entity reference.</summary>
|
||||
public ClusterNode? Node { get; set; }
|
||||
/// <summary>Gets or sets the deployment entity reference.</summary>
|
||||
public Deployment? Deployment { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,14 +3,19 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
|
||||
public sealed class PollGroup
|
||||
{
|
||||
/// <summary>Gets or sets the database row identifier for the polling group.</summary>
|
||||
public Guid PollGroupRowId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the unique identifier for the polling group.</summary>
|
||||
public required string PollGroupId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the driver instance that owns this polling group.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the display name of the polling group.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the poll interval in milliseconds.</summary>
|
||||
public int IntervalMs { get; set; }
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </remarks>
|
||||
public sealed class Script
|
||||
{
|
||||
/// <summary>Gets or sets the script row identifier.</summary>
|
||||
public Guid ScriptRowId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id. Globally unique in v2.</summary>
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarm
|
||||
{
|
||||
/// <summary>Gets or sets the database row identifier for this scripted alarm.</summary>
|
||||
public Guid ScriptedAlarmRowId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id — drives <c>AlarmConditionType.ConditionName</c>. Globally unique in v2.</summary>
|
||||
@@ -52,6 +53,7 @@ public sealed class ScriptedAlarm
|
||||
/// </summary>
|
||||
public bool Retain { get; set; } = true;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this alarm is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
|
||||
@@ -45,13 +45,16 @@ public sealed class ScriptedAlarmState
|
||||
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
|
||||
public string? LastAckComment { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp of the last acknowledgment.</summary>
|
||||
public DateTime? LastAckUtc { get; set; }
|
||||
|
||||
/// <summary>User who last confirmed.</summary>
|
||||
public string? LastConfirmUser { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the operator-supplied confirm comment. Null if no comment or never confirmed.</summary>
|
||||
public string? LastConfirmComment { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp of the last confirmation.</summary>
|
||||
public DateTime? LastConfirmUtc { get; set; }
|
||||
|
||||
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class ServerCluster
|
||||
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the display name for the server cluster.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
|
||||
@@ -19,23 +20,33 @@ public sealed class ServerCluster
|
||||
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
|
||||
public required string Site { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the number of nodes in the cluster.</summary>
|
||||
public byte NodeCount { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the redundancy mode for the cluster.</summary>
|
||||
public required RedundancyMode RedundancyMode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the cluster is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Gets or sets optional notes about the cluster.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when the cluster was created.</summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Gets or sets the user who created the cluster.</summary>
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp when the cluster was last modified.</summary>
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the user who last modified the cluster.</summary>
|
||||
public string? ModifiedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
/// <summary>Gets or sets the collection of cluster nodes.</summary>
|
||||
public ICollection<ClusterNode> Nodes { get; set; } = [];
|
||||
/// <summary>Gets or sets the collection of namespaces in the cluster.</summary>
|
||||
public ICollection<Namespace> Namespaces { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -9,12 +9,24 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </summary>
|
||||
public sealed class Tag
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique database row identifier for the tag.
|
||||
/// </summary>
|
||||
public Guid TagRowId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag identifier.
|
||||
/// </summary>
|
||||
public required string TagId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the driver instance identifier for this tag.
|
||||
/// </summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the device identifier.
|
||||
/// </summary>
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -23,6 +35,9 @@ public sealed class Tag
|
||||
/// </summary>
|
||||
public string? EquipmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag name.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
|
||||
@@ -31,11 +46,17 @@ public sealed class Tag
|
||||
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
|
||||
public required string DataType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the access level for this tag.
|
||||
/// </summary>
|
||||
public required TagAccessLevel AccessLevel { get; set; }
|
||||
|
||||
/// <summary>Per decisions #44–45 — opt-in for write retry eligibility.</summary>
|
||||
public bool WriteIdempotent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the poll group identifier for batching read/write operations.
|
||||
/// </summary>
|
||||
public string? PollGroupId { get; set; }
|
||||
|
||||
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
|
||||
|
||||
@@ -3,19 +3,24 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsArea
|
||||
{
|
||||
/// <summary>Gets or sets the unique row identifier.</summary>
|
||||
public Guid UnsAreaRowId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UNS area identifier.</summary>
|
||||
public required string UnsAreaId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the cluster identifier.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Gets or sets optional notes for the area.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>Gets or sets the associated server cluster.</summary>
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsLine
|
||||
{
|
||||
/// <summary>Gets or sets the unique row identifier for this UNS line.</summary>
|
||||
public Guid UnsLineRowId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the unique identifier for this UNS line.</summary>
|
||||
public required string UnsLineId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>.</summary>
|
||||
@@ -13,6 +15,7 @@ public sealed class UnsLine
|
||||
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Gets or sets optional notes describing this UNS line.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
|
||||
@@ -20,9 +20,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
/// </remarks>
|
||||
public sealed class VirtualTag
|
||||
{
|
||||
/// <summary>Gets or sets the database row ID (primary key).</summary>
|
||||
public Guid VirtualTagRowId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id. Globally unique in v2.</summary>
|
||||
/// <summary>Gets or sets the stable logical identifier, globally unique in v2.</summary>
|
||||
public required string VirtualTagId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this virtual tag.</summary>
|
||||
@@ -43,9 +44,10 @@ public sealed class VirtualTag
|
||||
/// <summary>Timer re-evaluation cadence in milliseconds. <c>null</c> = no timer.</summary>
|
||||
public int? TimerIntervalMs { get; set; }
|
||||
|
||||
/// <summary>Per plan decision #10 — checkbox to route this tag's values through <c>IHistoryWriter</c>.</summary>
|
||||
/// <summary>Gets or sets a value indicating whether this tag's values should be historized.</summary>
|
||||
public bool Historize { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this virtual tag is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Optimistic concurrency token for last-write-wins detection in the v2 live-edit model.</summary>
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed class GenerationSealedCache
|
||||
/// <summary>Root directory for all clusters' sealed caches.</summary>
|
||||
public string CacheRoot => _cacheRoot;
|
||||
|
||||
/// <summary>Initializes a new instance of the GenerationSealedCache class.</summary>
|
||||
/// <param name="cacheRoot">The root directory for the cache.</param>
|
||||
public GenerationSealedCache(string cacheRoot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheRoot);
|
||||
@@ -43,6 +45,9 @@ public sealed class GenerationSealedCache
|
||||
/// mark the file read-only, then atomically publish the <c>CURRENT</c> pointer. Existing
|
||||
/// sealed files for prior generations are preserved (prune separately).
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The generation snapshot to seal.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task SealAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
@@ -88,6 +93,9 @@ public sealed class GenerationSealedCache
|
||||
/// (first-boot-no-snapshot case) or when the sealed file is corrupt. Never silently
|
||||
/// falls back to a prior generation.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The cluster ID to read the snapshot for.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation containing the generation snapshot.</returns>
|
||||
public Task<GenerationSnapshot> ReadCurrentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
@@ -135,6 +143,8 @@ public sealed class GenerationSealedCache
|
||||
}
|
||||
|
||||
/// <summary>Return the generation id the <c>CURRENT</c> pointer points at, or null if no pointer exists.</summary>
|
||||
/// <param name="clusterId">The cluster ID to get the current generation ID for.</param>
|
||||
/// <returns>The generation ID, or null if no pointer exists.</returns>
|
||||
public long? TryGetCurrentGenerationId(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
@@ -165,6 +175,11 @@ public sealed class GenerationSealedCache
|
||||
/// <summary>Sealed cache is unreachable — caller must fail closed.</summary>
|
||||
public sealed class GenerationCacheUnavailableException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the GenerationCacheUnavailableException class.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
public GenerationCacheUnavailableException(string message) : base(message) { }
|
||||
/// <summary>Initializes a new instance of the GenerationCacheUnavailableException class with an inner exception.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="inner">The inner exception.</param>
|
||||
public GenerationCacheUnavailableException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
/// </summary>
|
||||
public sealed class GenerationSnapshot
|
||||
{
|
||||
/// <summary>Gets or sets the auto-generated LiteDB ID.</summary>
|
||||
public int Id { get; set; } // LiteDB auto-ID
|
||||
/// <summary>Gets or sets the cluster identifier.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
/// <summary>Gets or sets the generation identifier.</summary>
|
||||
public required long GenerationId { get; set; }
|
||||
/// <summary>Gets or sets the time this snapshot was cached.</summary>
|
||||
public required DateTime CachedAt { get; set; }
|
||||
/// <summary>Gets or sets the JSON-serialized payload content.</summary>
|
||||
public required string PayloadJson { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
/// </remarks>
|
||||
public interface ILocalConfigCache
|
||||
{
|
||||
/// <summary>Retrieves the most recent generation snapshot for the specified cluster.</summary>
|
||||
/// <param name="clusterId">The cluster identifier.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The most recent generation snapshot, or null if none exists.</returns>
|
||||
Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default);
|
||||
/// <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>
|
||||
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>
|
||||
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
// page-level write, not the find-then-insert window.
|
||||
private readonly SemaphoreSlim _writeGate = new(initialCount: 1, maxCount: 1);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LiteDbConfigCache"/> class.</summary>
|
||||
/// <param name="dbPath">Path to the LiteDB database file.</param>
|
||||
public LiteDbConfigCache(string dbPath)
|
||||
{
|
||||
// LiteDB can be tolerant of header-only corruption at construction time (it may overwrite
|
||||
@@ -43,6 +45,9 @@ 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>
|
||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -53,6 +58,9 @@ 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>
|
||||
public async Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -81,6 +89,10 @@ 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>
|
||||
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -97,6 +109,7 @@ public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Releases all resources used by the cache.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_writeGate.Dispose();
|
||||
|
||||
@@ -27,6 +27,12 @@ public sealed class ResilientConfigReader
|
||||
private readonly ResiliencePipeline _pipeline;
|
||||
private readonly ILogger<ResilientConfigReader> _logger;
|
||||
|
||||
/// <summary>Initializes a resilient config reader with the given cache and options.</summary>
|
||||
/// <param name="cache">The sealed cache for fallback.</param>
|
||||
/// <param name="staleFlag">The stale config flag to manage.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="timeout">The timeout for central fetch (default 2s).</param>
|
||||
/// <param name="retryCount">The number of retries (default 3).</param>
|
||||
public ResilientConfigReader(
|
||||
GenerationSealedCache cache,
|
||||
StaleConfigFlag staleFlag,
|
||||
@@ -71,6 +77,9 @@ public sealed class ResilientConfigReader
|
||||
@"(?ix)\b(Password|Pwd|User\s*Id|Uid|AccessToken|Authorization|Api[-_]?Key)\s*=\s*[^;,)\s]*",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Redacts sensitive credential information from a message.</summary>
|
||||
/// <param name="message">The message to scrub.</param>
|
||||
/// <returns>The message with redacted credentials.</returns>
|
||||
internal static string ScrubSecrets(string? message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return message ?? string.Empty;
|
||||
@@ -80,10 +89,15 @@ public sealed class ResilientConfigReader
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute <paramref name="centralFetch"/> through the resilience pipeline. On full failure
|
||||
/// (post-retry), reads the sealed cache for <paramref name="clusterId"/> and passes the
|
||||
/// snapshot to <paramref name="fromSnapshot"/> to extract the requested shape.
|
||||
/// Executes a central fetch through the resilience pipeline. On full failure
|
||||
/// (post-retry), reads the sealed cache and extracts the requested shape.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of configuration to read.</typeparam>
|
||||
/// <param name="clusterId">The cluster ID to fetch for.</param>
|
||||
/// <param name="centralFetch">Function to fetch from central DB.</param>
|
||||
/// <param name="fromSnapshot">Function to extract the config from a snapshot.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The configuration of type T.</returns>
|
||||
public async ValueTask<T> ReadAsync<T>(
|
||||
string clusterId,
|
||||
Func<CancellationToken, ValueTask<T>> centralFetch,
|
||||
|
||||
@@ -12,39 +12,69 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||
: DbContext(options), IDataProtectionKeyContext
|
||||
{
|
||||
/// <summary>Gets the DbSet of server clusters.</summary>
|
||||
public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
|
||||
/// <summary>Gets the DbSet of cluster nodes.</summary>
|
||||
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
|
||||
/// <summary>Gets the DbSet of cluster node credentials.</summary>
|
||||
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
|
||||
/// <summary>Gets the DbSet of namespaces.</summary>
|
||||
public DbSet<Namespace> Namespaces => Set<Namespace>();
|
||||
/// <summary>Gets the DbSet of UNS areas.</summary>
|
||||
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
|
||||
/// <summary>Gets the DbSet of UNS lines.</summary>
|
||||
public DbSet<UnsLine> UnsLines => Set<UnsLine>();
|
||||
/// <summary>Gets the DbSet of driver instances.</summary>
|
||||
public DbSet<DriverInstance> DriverInstances => Set<DriverInstance>();
|
||||
/// <summary>Gets the DbSet of devices.</summary>
|
||||
public DbSet<Device> Devices => Set<Device>();
|
||||
/// <summary>Gets the DbSet of equipment.</summary>
|
||||
public DbSet<Equipment> Equipment => Set<Equipment>();
|
||||
/// <summary>Gets the DbSet of tags.</summary>
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
/// <summary>Gets the DbSet of poll groups.</summary>
|
||||
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
|
||||
/// <summary>Gets the DbSet of node ACLs.</summary>
|
||||
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||
/// <summary>Gets the DbSet of configuration audit logs.</summary>
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
/// <summary>Gets the DbSet of external ID reservations.</summary>
|
||||
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||
/// <summary>Gets the DbSet of driver host statuses.</summary>
|
||||
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
|
||||
/// <summary>Gets the DbSet of driver instance resilience statuses.</summary>
|
||||
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
|
||||
/// <summary>Gets the DbSet of LDAP group role mappings.</summary>
|
||||
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
|
||||
/// <summary>Gets the DbSet of equipment import batches.</summary>
|
||||
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
|
||||
/// <summary>Gets the DbSet of equipment import rows.</summary>
|
||||
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
|
||||
/// <summary>Gets the DbSet of scripts.</summary>
|
||||
public DbSet<Script> Scripts => Set<Script>();
|
||||
/// <summary>Gets the DbSet of virtual tags.</summary>
|
||||
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
|
||||
/// <summary>Gets the DbSet of scripted alarms.</summary>
|
||||
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
|
||||
/// <summary>Gets the DbSet of scripted alarm states.</summary>
|
||||
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
|
||||
|
||||
// v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment).
|
||||
/// <summary>Gets the DbSet of deployments.</summary>
|
||||
public DbSet<Deployment> Deployments => Set<Deployment>();
|
||||
/// <summary>Gets the DbSet of node deployment states.</summary>
|
||||
public DbSet<NodeDeploymentState> NodeDeploymentStates => Set<NodeDeploymentState>();
|
||||
/// <summary>Gets the DbSet of configuration edits.</summary>
|
||||
public DbSet<ConfigEdit> ConfigEdits => Set<ConfigEdit>();
|
||||
|
||||
// ASP.NET DataProtection key ring storage (decision: keys persisted in ConfigDb so every
|
||||
// admin-role node decrypts the same cookies without sharing a filesystem).
|
||||
/// <summary>Gets the DbSet of data protection keys.</summary>
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
/// <summary>Configures the entity model when the context is first created.</summary>
|
||||
/// <param name="modelBuilder">The model builder used to configure the context.</param>
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
@@ -12,6 +12,9 @@ public static class ServiceCollectionExtensions
|
||||
/// Registers <see cref="IDbContextFactory{TContext}"/> for <see cref="OtOpcUaConfigDbContext"/>
|
||||
/// using the connection string named <c>ConfigDb</c> from <see cref="IConfiguration"/>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to configure.</param>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddOtOpcUaConfigDb(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString(ConnectionStringName)
|
||||
|
||||
@@ -22,10 +22,13 @@ public interface ILdapGroupRoleMappingService
|
||||
/// Hot path — fires on every sign-in. The default EF implementation relies on the
|
||||
/// <c>IX_LdapGroupRoleMapping_Group</c> index. Case-insensitive per LDAP conventions.
|
||||
/// </remarks>
|
||||
/// <param name="ldapGroups">The LDAP groups to search for.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
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>
|
||||
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Create a new grant.</summary>
|
||||
@@ -34,14 +37,20 @@ public interface ILdapGroupRoleMappingService
|
||||
/// ClusterId, duplicate (group, cluster) pair, etc.) — ValidatedLdapGroupRoleMappingService
|
||||
/// is the write surface that enforces these; the raw service here surfaces DB-level violations.
|
||||
/// </exception>
|
||||
/// <param name="row">The LDAP group role mapping to create.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
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>
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Thrown when <see cref="LdapGroupRoleMapping"/> authoring violates an invariant.</summary>
|
||||
public sealed class InvalidLdapGroupRoleMappingException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the InvalidLdapGroupRoleMappingException.</summary>
|
||||
/// <param name="message">The error message.</param>
|
||||
public InvalidLdapGroupRoleMappingException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ 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>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -24,6 +28,9 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Lists all LDAP group role mappings.</summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>All role mappings ordered by group and cluster ID.</returns>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> await db.LdapGroupRoleMappings
|
||||
.AsNoTracking()
|
||||
@@ -32,6 +39,10 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>Creates a new LDAP group role mapping.</summary>
|
||||
/// <param name="row">The mapping to create.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The created mapping with generated ID and timestamp.</returns>
|
||||
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
@@ -45,6 +56,10 @@ public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILd
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>Deletes an LDAP group role mapping.</summary>
|
||||
/// <param name="id">The mapping identifier.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that completes when the deletion is done.</returns>
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.LdapGroupRoleMappings.FindAsync([id], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -8,7 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
/// </summary>
|
||||
public sealed class DraftSnapshot
|
||||
{
|
||||
/// <summary>Gets the draft generation identifier.</summary>
|
||||
public required long GenerationId { get; init; }
|
||||
/// <summary>Gets the cluster identifier.</summary>
|
||||
public required string ClusterId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
@@ -23,13 +25,21 @@ public sealed class DraftSnapshot
|
||||
/// </summary>
|
||||
public string? Site { get; init; }
|
||||
|
||||
/// <summary>Gets the list of OPC UA namespaces.</summary>
|
||||
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
|
||||
/// <summary>Gets the list of driver instances.</summary>
|
||||
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
||||
/// <summary>Gets the list of devices.</summary>
|
||||
public IReadOnlyList<Device> Devices { get; init; } = [];
|
||||
/// <summary>Gets the list of UNS areas.</summary>
|
||||
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
|
||||
/// <summary>Gets the list of UNS lines.</summary>
|
||||
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
|
||||
/// <summary>Gets the list of equipment.</summary>
|
||||
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
|
||||
/// <summary>Gets the list of tags.</summary>
|
||||
public IReadOnlyList<Tag> Tags { get; init; } = [];
|
||||
/// <summary>Gets the list of poll groups.</summary>
|
||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||
|
||||
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
|
||||
|
||||
@@ -17,6 +17,10 @@ public static class DraftValidator
|
||||
private const string UnsDefaultSegment = "_default";
|
||||
private const int MaxPathLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a draft snapshot and returns all validation errors found in a single pass.
|
||||
/// </summary>
|
||||
/// <param name="draft">The draft snapshot to validate.</param>
|
||||
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
@@ -142,6 +146,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>
|
||||
public static string DeriveEquipmentId(Guid uuid) =>
|
||||
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
|
||||
|
||||
@@ -196,6 +201,8 @@ public static class DraftValidator
|
||||
/// <see cref="DraftSnapshot"/>. Returns every failing rule in one pass, same shape as
|
||||
/// <see cref="Validate"/>.
|
||||
/// </remarks>
|
||||
/// <param name="cluster">The server cluster to validate.</param>
|
||||
/// <param name="clusterNodes">The cluster nodes to validate against the cluster configuration.</param>
|
||||
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||
ServerCluster cluster,
|
||||
IReadOnlyList<ClusterNode> clusterNodes)
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class DriverTypeRegistry
|
||||
/// <see cref="_writeLock"/> so concurrent <see cref="Register"/> calls cannot silently
|
||||
/// discard each other's registrations — see Core.Abstractions-004.
|
||||
/// </remarks>
|
||||
/// <param name="metadata">The driver type metadata to register.</param>
|
||||
public void Register(DriverTypeMetadata metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
@@ -53,6 +54,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>
|
||||
public DriverTypeMetadata Get(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
@@ -66,6 +68,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>
|
||||
public DriverTypeMetadata? TryGet(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
|
||||
@@ -23,6 +23,11 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// <summary>
|
||||
/// Read raw historical samples for a single tag over a time range.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference of the tag to read.</param>
|
||||
/// <param name="startUtc">The start of the time range in UTC.</param>
|
||||
/// <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>
|
||||
Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -35,6 +40,12 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// A bucket with no source data returns a sample whose
|
||||
/// <see cref="DataValueSnapshot.StatusCode"/> indicates BadNoData.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference of the tag to read.</param>
|
||||
/// <param name="startUtc">The start of the time range in UTC.</param>
|
||||
/// <param name="endUtc">The end of the time range in UTC.</param>
|
||||
/// <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>
|
||||
Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -49,6 +60,9 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// backend's policy. The returned list MUST be the same length and order as
|
||||
/// <paramref name="timestampsUtc"/>; gaps are returned as Bad-quality snapshots.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
@@ -74,6 +88,11 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// methods so legacy drivers can stay raw-only. The asymmetry is intentional
|
||||
/// (Core.Abstractions-008).
|
||||
/// </remarks>
|
||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||
/// <param name="startUtc">The start of the time range in UTC.</param>
|
||||
/// <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>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
|
||||
@@ -34,6 +34,9 @@ public interface IAddressSpaceBuilder
|
||||
/// read once at build time (e.g. OPC 40010 Identification fields per the schemas-repo
|
||||
/// <c>_base</c> equipment-class template).
|
||||
/// </summary>
|
||||
/// <param name="browseName">The property browse name.</param>
|
||||
/// <param name="dataType">The property data type.</param>
|
||||
/// <param name="value">The property value.</param>
|
||||
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
||||
}
|
||||
|
||||
@@ -52,6 +55,7 @@ public interface IVariableHandle
|
||||
/// to surface the state (e.g. OPC UA <c>AlarmConditionState.Activate</c>,
|
||||
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
||||
/// </summary>
|
||||
/// <param name="info">The alarm condition information.</param>
|
||||
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
||||
}
|
||||
|
||||
@@ -107,5 +111,6 @@ public sealed record AlarmConditionInfo(
|
||||
public interface IAlarmConditionSink
|
||||
{
|
||||
/// <summary>Push an alarm transition (Active / Acknowledged / Inactive) for this condition.</summary>
|
||||
/// <param name="args">The alarm event arguments.</param>
|
||||
void OnTransition(AlarmEventArgs args);
|
||||
}
|
||||
|
||||
@@ -11,14 +11,20 @@ public interface IAlarmSource
|
||||
/// Subscribe to alarm events for a node-set (typically: a folder or equipment subtree).
|
||||
/// The driver fires <see cref="OnAlarmEvent"/> for every alarm transition.
|
||||
/// </summary>
|
||||
/// <param name="sourceNodeIds">The driver node IDs to subscribe to.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -21,6 +21,8 @@ public interface IDriver
|
||||
string DriverType { get; }
|
||||
|
||||
/// <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>
|
||||
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -33,9 +35,12 @@ public interface IDriver
|
||||
/// only Core-initiated recovery path for in-process drivers; if it fails, the driver instance
|
||||
/// is marked Faulted and its nodes go Bad quality, but the server process keeps running.
|
||||
/// </remarks>
|
||||
/// <param name="driverConfigJson">The driver configuration as JSON.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
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>
|
||||
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
|
||||
@@ -57,5 +62,6 @@ public interface IDriver
|
||||
/// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget.
|
||||
/// Required-for-correctness state must NOT be flushed.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public interface IDriverFactory
|
||||
/// <c>null</c> when no factory is registered for that type (missing assembly, typo, etc.).
|
||||
/// The DriverHostActor logs + skips the row rather than failing the whole apply.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The driver type name (e.g., "Modbus", "FOCAS").</param>
|
||||
/// <param name="driverInstanceId">The driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>A new IDriver instance, or null if the driver type is not supported.</returns>
|
||||
IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson);
|
||||
|
||||
/// <summary>Driver-type names this factory can materialise. Mostly for diagnostics + logs.</summary>
|
||||
@@ -30,6 +34,12 @@ 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>
|
||||
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null;
|
||||
/// <summary>Gets the collection of supported driver types (empty in this null implementation).</summary>
|
||||
public IReadOnlyCollection<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ public interface IHistoryProvider
|
||||
/// Read raw historical samples for a single attribute over a time range.
|
||||
/// The Core wraps this with continuation-point handling.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference path to the attribute.</param>
|
||||
/// <param name="startUtc">Inclusive lower bound on the time range (UTC).</param>
|
||||
/// <param name="endUtc">Exclusive upper bound on the time range (UTC).</param>
|
||||
/// <param name="maxValuesPerNode">Maximum number of values to return per node.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
||||
/// <returns>A task that returns the history read result containing samples and optional continuation point.</returns>
|
||||
Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -31,6 +37,13 @@ public interface IHistoryProvider
|
||||
/// Read processed (aggregated) samples — interval-bucketed average / min / max / etc.
|
||||
/// Optional — drivers that only support raw history can throw <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference path to the attribute.</param>
|
||||
/// <param name="startUtc">Inclusive lower bound on the time range (UTC).</param>
|
||||
/// <param name="endUtc">Exclusive upper bound on the time range (UTC).</param>
|
||||
/// <param name="interval">The time interval for bucketing samples.</param>
|
||||
/// <param name="aggregate">The aggregate function to apply to each bucket.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
||||
/// <returns>A task that returns the history read result containing aggregated samples and optional continuation point.</returns>
|
||||
Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
@@ -49,6 +62,10 @@ public interface IHistoryProvider
|
||||
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
|
||||
/// they may not have a backend for.
|
||||
/// </remarks>
|
||||
/// <param name="fullReference">The full reference path to the attribute.</param>
|
||||
/// <param name="timestampsUtc">The list of timestamps at which to read values.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to stop the operation.</param>
|
||||
/// <returns>A task that returns the history read result containing samples at the requested times.</returns>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
|
||||
@@ -30,5 +30,7 @@ public interface IPerCallHostResolver
|
||||
/// used as the <c>hostName</c> argument to the Phase 6.1 <c>CapabilityInvoker</c> so
|
||||
/// per-host breaker isolation + per-host bulkhead accounting both kick in.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The full reference of the tag or resource.</param>
|
||||
/// <returns>The host name responsible for serving the reference.</returns>
|
||||
string ResolveHost(string fullReference);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ public interface IReadable
|
||||
/// Per-reference failures should be reported via the snapshot's <see cref="DataValueSnapshot.StatusCode"/>
|
||||
/// (Bad-coded), not as exceptions. The whole call should throw only if the driver itself is unreachable.
|
||||
/// </remarks>
|
||||
/// <param name="fullReferences">The list of full driver-side references to read.</param>
|
||||
/// <param name="cancellationToken">A cancellation token for the operation.</param>
|
||||
/// <returns>A task that returns a read-only list of data value snapshots, one per requested reference in the same order.</returns>
|
||||
Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -14,6 +14,9 @@ public interface ISubscribable
|
||||
/// The driver MAY fire <see cref="OnDataChange"/> immediately with the current value
|
||||
/// (initial-data callback per OPC UA convention) and again on every change.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The full references of the attributes to subscribe to.</param>
|
||||
/// <param name="publishingInterval">The desired interval at which to receive data change notifications.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>An opaque subscription handle the caller passes to <see cref="UnsubscribeAsync"/>.</returns>
|
||||
Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
@@ -21,6 +24,9 @@ public interface ISubscribable
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
|
||||
/// <param name="handle">The subscription handle to cancel.</param>
|
||||
/// <param name="cancellationToken">A cancellation token to observe for cancellation.</param>
|
||||
/// <returns>A task representing the asynchronous unsubscribe operation.</returns>
|
||||
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -11,5 +11,7 @@ public interface ITagDiscovery
|
||||
/// Discover the driver's tag set and stream nodes to the builder.
|
||||
/// The driver decides ordering (root → leaf typically) and may yield as many calls as needed.
|
||||
/// </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>
|
||||
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ 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>
|
||||
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReferences);
|
||||
@@ -81,6 +83,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
|
||||
/// <summary>Cancel the background loop for a handle returned by <see cref="Subscribe"/>.</summary>
|
||||
/// <returns><c>true</c> when the handle was known to the engine and has been torn down.</returns>
|
||||
/// <param name="handle">The subscription handle to cancel.</param>
|
||||
public bool Unsubscribe(ISubscriptionHandle handle)
|
||||
{
|
||||
if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
@@ -235,6 +238,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
/// <summary>Gets the cache of last-seen values per tag reference.</summary>
|
||||
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -249,6 +253,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
|
||||
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets a diagnostic identifier for this subscription.</summary>
|
||||
public string DiagnosticId => $"poll-sub-{Id}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
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>
|
||||
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Snapshot of current queue depth + drain health.</summary>
|
||||
@@ -34,7 +36,11 @@ public interface IAlarmHistorianSink
|
||||
public sealed class NullAlarmHistorianSink : IAlarmHistorianSink
|
||||
{
|
||||
public static readonly NullAlarmHistorianSink Instance = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistorianSinkStatus GetStatus() => new(
|
||||
QueueDepth: 0,
|
||||
DeadLetterDepth: 0,
|
||||
@@ -89,6 +95,8 @@ public enum HistorianWriteOutcome
|
||||
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>
|
||||
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,16 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
/// <summary>Test-only: number of times the perf-optimised path fell through to a real <c>COUNT(*)</c>.</summary>
|
||||
public long DebugCapacityProbeCount => Interlocked.Read(ref _capacityProbeCount);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteStoreAndForwardSink"/> class with the specified configuration.
|
||||
/// </summary>
|
||||
/// <param name="databasePath">The filesystem path to the SQLite database file.</param>
|
||||
/// <param name="writer">The alarm historian writer to handle batch forwarding.</param>
|
||||
/// <param name="logger">The logger for diagnostic output.</param>
|
||||
/// <param name="batchSize">The maximum number of rows to forward in a single batch. Defaults to 100.</param>
|
||||
/// <param name="capacity">The maximum queue capacity before evicting oldest rows. Defaults to 1,000,000.</param>
|
||||
/// <param name="deadLetterRetention">The timespan to retain dead-lettered rows before purging. Defaults to 30 days.</param>
|
||||
/// <param name="clock">Optional clock function for testing; defaults to <see cref="DateTime.UtcNow"/>.</param>
|
||||
public SqliteStoreAndForwardSink(
|
||||
string databasePath,
|
||||
IAlarmHistorianWriter writer,
|
||||
@@ -182,6 +192,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
/// <see cref="GetStatus"/> rather than being lost as an unobserved task
|
||||
/// exception (Core.AlarmHistorian-006).
|
||||
/// </remarks>
|
||||
/// <param name="tickInterval">The base interval between drain attempts.</param>
|
||||
public void StartDrainLoop(TimeSpan tickInterval)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
|
||||
@@ -231,11 +242,19 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
catch (ObjectDisposedException) { /* raced with Dispose — nothing to re-arm */ }
|
||||
}
|
||||
|
||||
// Core.AlarmHistorian-003: use async SQLite APIs so the emitting thread is not
|
||||
// blocked waiting for a file-lock or disk write; honor the cancellationToken
|
||||
// throughout. Microsoft.Data.Sqlite's async surface (OpenAsync /
|
||||
// ExecuteNonQueryAsync) is a thin wrapper over the synchronous path, so the
|
||||
// blocking still happens — but on a thread-pool thread, not the caller's thread.
|
||||
/// <summary>
|
||||
/// Enqueues an alarm historian event asynchronously for forwarding to the historian.
|
||||
/// Respects the queue capacity and enforces eviction of oldest rows when full.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Core.AlarmHistorian-003: use async SQLite APIs so the emitting thread is not
|
||||
/// blocked waiting for a file-lock or disk write; honor the cancellationToken
|
||||
/// throughout. Microsoft.Data.Sqlite's async surface (OpenAsync /
|
||||
/// ExecuteNonQueryAsync) is a thin wrapper over the synchronous path, so the
|
||||
/// blocking still happens — but on a thread-pool thread, not the caller's thread.
|
||||
/// </remarks>
|
||||
/// <param name="evt">The alarm historian event to enqueue.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is null) throw new ArgumentNullException(nameof(evt));
|
||||
@@ -325,6 +344,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
/// outcome-applying transaction). Pre-fix the drain opened three independent
|
||||
/// connections per tick, each paying the open + PRAGMA cost.
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
public async Task DrainOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -470,6 +490,7 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the current status of the historian sink including queue depth and drain state.</summary>
|
||||
public HistorianSinkStatus GetStatus()
|
||||
{
|
||||
// Core.AlarmHistorian-008: read the non-dead-lettered count from the in-memory
|
||||
@@ -676,8 +697,11 @@ public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
|
||||
private void BumpBackoff() => _backoffIndex = Math.Min(_backoffIndex + 1, BackoffLadder.Length - 1);
|
||||
private void ResetBackoff() => _backoffIndex = 0;
|
||||
|
||||
/// <summary>Gets the current exponential backoff delay for retry operations.</summary>
|
||||
public TimeSpan CurrentBackoff => BackoffLadder[_backoffIndex];
|
||||
|
||||
/// <summary>Disposes the sink and releases all held resources including the drain timer.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -44,6 +44,9 @@ public sealed record AlarmConditionState(
|
||||
ImmutableList<AlarmComment> Comments)
|
||||
{
|
||||
/// <summary>Initial-load state for a newly registered alarm — everything in the "no-event" position.</summary>
|
||||
/// <param name="alarmId">The unique identifier for the alarm.</param>
|
||||
/// <param name="nowUtc">The current UTC timestamp.</param>
|
||||
/// <returns>A fresh AlarmConditionState with all fields initialized to default/inactive values.</returns>
|
||||
public static AlarmConditionState Fresh(string alarmId, DateTime nowUtc) => new(
|
||||
AlarmId: alarmId,
|
||||
Enabled: AlarmEnabledState.Enabled,
|
||||
|
||||
@@ -20,6 +20,10 @@ public sealed class AlarmPredicateContext : ScriptContext
|
||||
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AlarmPredicateContext"/> class.</summary>
|
||||
/// <param name="readCache">The cached read values from tags.</param>
|
||||
/// <param name="logger">The logger for diagnostics.</param>
|
||||
/// <param name="clock">Optional custom clock for testing.</param>
|
||||
public AlarmPredicateContext(
|
||||
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
|
||||
ILogger logger,
|
||||
@@ -30,6 +34,9 @@ public sealed class AlarmPredicateContext : ScriptContext
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Gets a tag value from the read cache.</summary>
|
||||
/// <param name="path">The tag path to retrieve.</param>
|
||||
/// <inheritdoc />
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
@@ -39,6 +46,10 @@ public sealed class AlarmPredicateContext : ScriptContext
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, _clock());
|
||||
}
|
||||
|
||||
/// <summary>Rejects virtual tag writes for pure predicate semantics.</summary>
|
||||
/// <param name="path">The virtual tag path.</param>
|
||||
/// <param name="value">The value to write.</param>
|
||||
/// <inheritdoc />
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
// Predicates must be pure — writing from an alarm script couples alarm state to
|
||||
@@ -49,7 +60,9 @@ public sealed class AlarmPredicateContext : ScriptContext
|
||||
"into a virtual tag whose value the alarm predicate then reads.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTime Now => _clock();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ILogger Logger { get; }
|
||||
}
|
||||
|
||||
@@ -15,9 +15,27 @@ namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
/// </remarks>
|
||||
public interface IAlarmStateStore
|
||||
{
|
||||
/// <summary>Loads the alarm state for a specific alarm identifier.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The alarm state if found; otherwise null.</returns>
|
||||
Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct);
|
||||
|
||||
/// <summary>Loads all alarm states from the store.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A collection of all alarm states.</returns>
|
||||
Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Saves an alarm state to the store.</summary>
|
||||
/// <param name="state">The alarm state to save.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task representing the save operation.</returns>
|
||||
Task SaveAsync(AlarmConditionState state, CancellationToken ct);
|
||||
|
||||
/// <summary>Removes an alarm state from the store.</summary>
|
||||
/// <param name="alarmId">The alarm identifier to remove.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A task representing the remove operation.</returns>
|
||||
Task RemoveAsync(string alarmId, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -27,18 +45,22 @@ public sealed class InMemoryAlarmStateStore : IAlarmStateStore
|
||||
private readonly ConcurrentDictionary<string, AlarmConditionState> _map
|
||||
= new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
=> Task.FromResult(_map.TryGetValue(alarmId, out var v) ? v : null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<AlarmConditionState>>(_map.Values.ToArray());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
_map[state.AlarmId] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
_map.TryRemove(alarmId, out _);
|
||||
|
||||
@@ -43,6 +43,9 @@ public static class MessageTemplate
|
||||
/// "Input-quality policy" section in <c>docs/ScriptedAlarms.md</c>.
|
||||
/// (Core.ScriptedAlarms-010)
|
||||
/// </remarks>
|
||||
/// <param name="template">The template string with {path} tokens.</param>
|
||||
/// <param name="resolveTag">A function to resolve tag values by path.</param>
|
||||
/// <returns>The resolved template string with tokens replaced or marked as unresolvable.</returns>
|
||||
public static string Resolve(string template, Func<string, DataValueSnapshot?> resolveTag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template)) return template ?? string.Empty;
|
||||
@@ -60,6 +63,8 @@ public static class MessageTemplate
|
||||
}
|
||||
|
||||
/// <summary>Enumerate the token paths the template references. Used at publish time to validate references exist.</summary>
|
||||
/// <param name="template">The template string to extract tokens from.</param>
|
||||
/// <returns>A list of all token paths found in the template.</returns>
|
||||
public static IReadOnlyList<string> ExtractTokenPaths(string? template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template)) return Array.Empty<string>();
|
||||
|
||||
@@ -32,6 +32,10 @@ public static class Part9StateMachine
|
||||
/// branch-stack increment when a new active arrives while prior active is
|
||||
/// still un-acked, and shelving suppression.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="predicateTrue">Whether the predicate is true.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyPredicate(
|
||||
AlarmConditionState current,
|
||||
bool predicateTrue,
|
||||
@@ -86,6 +90,11 @@ public static class Part9StateMachine
|
||||
}
|
||||
|
||||
/// <summary>Operator acknowledges the currently-active transition.</summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user who is acknowledging.</param>
|
||||
/// <param name="comment">An optional comment.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyAcknowledge(
|
||||
AlarmConditionState current,
|
||||
string user,
|
||||
@@ -112,6 +121,11 @@ public static class Part9StateMachine
|
||||
}
|
||||
|
||||
/// <summary>Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.</summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user who is confirming.</param>
|
||||
/// <param name="comment">An optional comment.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyConfirm(
|
||||
AlarmConditionState current,
|
||||
string user,
|
||||
@@ -137,6 +151,13 @@ public static class Part9StateMachine
|
||||
return new TransitionResult(next, EmissionKind.Confirmed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a one-shot shelving action.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user applying the shelving.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyOneShotShelve(
|
||||
AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
@@ -154,6 +175,14 @@ public static class Part9StateMachine
|
||||
return new TransitionResult(next, EmissionKind.Shelved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a timed shelving action.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user applying the shelving.</param>
|
||||
/// <param name="unshelveAtUtc">The UTC time at which the alarm should be unshelved.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyTimedShelve(
|
||||
AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
|
||||
{
|
||||
@@ -172,6 +201,13 @@ public static class Part9StateMachine
|
||||
return new TransitionResult(next, EmissionKind.Shelved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an unshelving action.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user applying the unshelving.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
@@ -188,6 +224,13 @@ public static class Part9StateMachine
|
||||
return new TransitionResult(next, EmissionKind.Unshelved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an enable action.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user enabling the alarm.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
@@ -204,6 +247,13 @@ public static class Part9StateMachine
|
||||
return new TransitionResult(next, EmissionKind.Enabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a disable action.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user disabling the alarm.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
@@ -220,6 +270,14 @@ public static class Part9StateMachine
|
||||
return new TransitionResult(next, EmissionKind.Disabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an add comment action.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="user">The user adding the comment.</param>
|
||||
/// <param name="text">The comment text.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyAddComment(
|
||||
AlarmConditionState current, string user, string text, DateTime nowUtc)
|
||||
{
|
||||
@@ -236,6 +294,9 @@ public static class Part9StateMachine
|
||||
/// the (possibly unshelved) state + emission hint so the engine knows to
|
||||
/// publish an Unshelved event at the right moment.
|
||||
/// </summary>
|
||||
/// <param name="current">The current alarm condition state.</param>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
|
||||
{
|
||||
if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
|
||||
@@ -284,7 +345,19 @@ public static class Part9StateMachine
|
||||
/// </remarks>
|
||||
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission, string? NoOpReason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a transition result with no change and no emission.
|
||||
/// </summary>
|
||||
/// <param name="state">The alarm condition state.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a transition result indicating a no-op operation with a reason.
|
||||
/// </summary>
|
||||
/// <param name="state">The alarm condition state.</param>
|
||||
/// <param name="reason">The reason for the no-op.</param>
|
||||
/// <returns>The transition result.</returns>
|
||||
public static TransitionResult NoOp(AlarmConditionState state, string reason)
|
||||
=> new(state, EmissionKind.None, reason);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// deterministic upstream push. Anything more involved should snapshot a
|
||||
/// copy under the gate. (Core.ScriptedAlarms-013.)
|
||||
/// </remarks>
|
||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
||||
internal IReadOnlyDictionary<string, DataValueSnapshot>? TryGetScratchReadCacheForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.ReadCache : null;
|
||||
|
||||
@@ -111,6 +112,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// for reference-identity assertions on a quiesced engine.
|
||||
/// (Core.ScriptedAlarms-013.)
|
||||
/// </remarks>
|
||||
/// <param name="alarmId">The alarm identifier to look up.</param>
|
||||
internal AlarmPredicateContext? TryGetScratchContextForTest(string alarmId)
|
||||
=> _scratchByAlarmId.TryGetValue(alarmId, out var s) ? s.Context : null;
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
||||
@@ -133,6 +135,15 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
private readonly HashSet<Task> _inFlight = [];
|
||||
private readonly object _inFlightLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new ScriptedAlarmEngine with the provided dependencies.
|
||||
/// </summary>
|
||||
/// <param name="upstream">The upstream tag source for reading tag values.</param>
|
||||
/// <param name="store">The alarm state store for persistence.</param>
|
||||
/// <param name="loggerFactory">The factory for creating alarm loggers.</param>
|
||||
/// <param name="engineLogger">The logger for engine-level diagnostics.</param>
|
||||
/// <param name="clock">Optional function providing the current UTC time; defaults to DateTime.UtcNow.</param>
|
||||
/// <param name="scriptTimeout">Optional timeout for script execution; defaults to the evaluator's default timeout.</param>
|
||||
public ScriptedAlarmEngine(
|
||||
ITagUpstreamSource upstream,
|
||||
IAlarmStateStore store,
|
||||
@@ -152,6 +163,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// <summary>Raised for every emission the Part9StateMachine produces that the engine should publish.</summary>
|
||||
public event EventHandler<ScriptedAlarmEvent>? OnEvent;
|
||||
|
||||
/// <summary>Gets the collection of loaded alarm identifiers.</summary>
|
||||
public IReadOnlyCollection<string> LoadedAlarmIds => _alarms.Keys.ToArray();
|
||||
|
||||
/// <summary>
|
||||
@@ -161,6 +173,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// the store (falling back to Fresh for first-load alarms), and recomputes
|
||||
/// ActiveState per Phase 7 plan decision #14 (startup recovery).
|
||||
/// </summary>
|
||||
/// <param name="definitions">The alarm definitions to load.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
|
||||
@@ -291,33 +305,71 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// Current persisted state for <paramref name="alarmId"/>. Returns null for
|
||||
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
|
||||
/// </summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
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>
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <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>
|
||||
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
|
||||
|
||||
/// <summary>Confirms the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <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>
|
||||
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
|
||||
|
||||
/// <summary>Applies a one-shot shelve to the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the shelve operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
|
||||
|
||||
/// <summary>Applies a timed shelve to the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <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>
|
||||
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
|
||||
|
||||
/// <summary>Removes any shelve from the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the unshelve operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
|
||||
|
||||
/// <summary>Enables the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the enable operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
|
||||
|
||||
/// <summary>Disables the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user performing the disable operation.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
|
||||
|
||||
/// <summary>Adds a comment to the specified alarm on behalf of the given user.</summary>
|
||||
/// <param name="alarmId">The alarm identifier.</param>
|
||||
/// <param name="user">The user adding the comment.</param>
|
||||
/// <param name="text">The comment text.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
|
||||
|
||||
@@ -369,6 +421,8 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// so driver-side dispatch isn't blocked; the background task is tracked so
|
||||
/// <see cref="Dispose"/> can drain it. (Core.ScriptedAlarms-006)
|
||||
/// </summary>
|
||||
/// <param name="path">The upstream tag path that changed.</param>
|
||||
/// <param name="value">The new data value snapshot.</param>
|
||||
internal void OnUpstreamChange(string path, DataValueSnapshot value)
|
||||
{
|
||||
_valueCache[path] = value;
|
||||
@@ -658,6 +712,7 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
"ScriptedAlarmEngine not loaded. Call LoadAsync first.");
|
||||
}
|
||||
|
||||
/// <summary>Disposes the engine, cleaning up resources and waiting for in-flight background tasks.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -729,9 +784,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
/// </remarks>
|
||||
private sealed class AlarmScratch
|
||||
{
|
||||
/// <summary>Gets the read cache dictionary containing current upstream tag values.</summary>
|
||||
public Dictionary<string, DataValueSnapshot> ReadCache { get; }
|
||||
/// <summary>Gets the predicate evaluation context.</summary>
|
||||
public AlarmPredicateContext Context { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new AlarmScratch with the specified inputs, logger, and clock.
|
||||
/// </summary>
|
||||
/// <param name="inputs">The set of input tag paths this alarm references.</param>
|
||||
/// <param name="logger">The logger for this alarm's diagnostics.</param>
|
||||
/// <param name="clock">Function providing the current UTC time.</param>
|
||||
public AlarmScratch(IReadOnlySet<string> inputs, ILogger logger, Func<DateTime> clock)
|
||||
{
|
||||
// Pre-size to the expected input count so the first refill doesn't pay the
|
||||
@@ -768,6 +831,14 @@ public sealed record ScriptedAlarmEvent(
|
||||
/// </summary>
|
||||
public interface ITagUpstreamSource
|
||||
{
|
||||
/// <summary>Reads a tag value synchronously.</summary>
|
||||
/// <param name="path">The tag path to read.</param>
|
||||
/// <returns>A data value snapshot containing the tag value and status.</returns>
|
||||
DataValueSnapshot ReadTag(string path);
|
||||
|
||||
/// <summary>Subscribes to upstream tag changes.</summary>
|
||||
/// <param name="path">The tag path to observe.</param>
|
||||
/// <param name="observer">Callback invoked when the tag value changes.</param>
|
||||
/// <returns>A subscription handle that removes the observer when disposed.</returns>
|
||||
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||
}
|
||||
|
||||
@@ -31,14 +31,18 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
= new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptedAlarmSource"/> class.</summary>
|
||||
/// <param name="engine">The scripted alarm engine to expose.</param>
|
||||
public ScriptedAlarmSource(ScriptedAlarmEngine engine)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_engine.OnEvent += OnEngineEvent;
|
||||
}
|
||||
|
||||
/// <summary>Occurs when an alarm event is raised by the engine.</summary>
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -49,6 +53,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
||||
@@ -56,6 +61,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -104,6 +110,7 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Releases resources used by the <see cref="ScriptedAlarmSource"/>.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -114,7 +121,10 @@ public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
|
||||
private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="SubscriptionHandle"/> class.</summary>
|
||||
/// <param name="id">The diagnostic ID for this subscription handle.</param>
|
||||
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||
/// <summary>Gets the diagnostic ID that uniquely identifies this subscription.</summary>
|
||||
public string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
|
||||
/// the next call retries (useful during Admin UI authoring when the operator is
|
||||
/// still fixing syntax).
|
||||
/// </summary>
|
||||
/// <param name="scriptSource">The source code to compile or retrieve from cache.</param>
|
||||
/// <returns>The compiled script evaluator.</returns>
|
||||
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
|
||||
{
|
||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||
@@ -116,6 +118,8 @@ public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
|
||||
/// <param name="scriptSource">The source code to check for in the cache.</param>
|
||||
/// <returns>True if the source is cached, false otherwise.</returns>
|
||||
public bool Contains(string scriptSource)
|
||||
=> _cache.ContainsKey(HashSource(scriptSource));
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ public static class DependencyExtractor
|
||||
/// Parse <paramref name="scriptSource"/> + return the inferred read + write tag
|
||||
/// paths, or a list of rejection messages if non-literal paths were used.
|
||||
/// </summary>
|
||||
/// <param name="scriptSource">The script source code to analyze.</param>
|
||||
public static DependencyExtractionResult Extract(string scriptSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptSource))
|
||||
@@ -65,10 +66,14 @@ public static class DependencyExtractor
|
||||
private readonly HashSet<string> _writes = new(StringComparer.Ordinal);
|
||||
private readonly List<DependencyRejection> _rejections = [];
|
||||
|
||||
/// <summary>Gets the set of tags read by the script.</summary>
|
||||
public IReadOnlySet<string> Reads => _reads;
|
||||
/// <summary>Gets the set of tags written by the script.</summary>
|
||||
public IReadOnlySet<string> Writes => _writes;
|
||||
/// <summary>Gets the list of rejections for non-literal tag paths.</summary>
|
||||
public IReadOnlyList<DependencyRejection> Rejections => _rejections;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||
{
|
||||
// Only interested in ctx.GetTag(...) / ctx.SetVirtualTag(...) — member-access
|
||||
|
||||
@@ -143,6 +143,8 @@ public static class ForbiddenTypeAnalyzer
|
||||
/// Returns empty list when the script is clean; non-empty list means the script
|
||||
/// must be rejected at publish with the rejections surfaced to the operator.
|
||||
/// </summary>
|
||||
/// <param name="compilation">The compilation to analyze.</param>
|
||||
/// <returns>A list of forbidden type rejections, or empty if the script is clean.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The walker has two passes per node. Pass (1) is the member / call surface:
|
||||
@@ -314,8 +316,11 @@ public sealed record ForbiddenTypeRejection(
|
||||
/// post-compile forbidden-type analyzer finds references to denied namespaces.</summary>
|
||||
public sealed class ScriptSandboxViolationException : Exception
|
||||
{
|
||||
/// <summary>Gets the list of forbidden type rejections that triggered this exception.</summary>
|
||||
public IReadOnlyList<ForbiddenTypeRejection> Rejections { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptSandboxViolationException"/> class.</summary>
|
||||
/// <param name="rejections">The list of forbidden type rejections found in the script.</param>
|
||||
public ScriptSandboxViolationException(IReadOnlyList<ForbiddenTypeRejection> rejections)
|
||||
: base(BuildMessage(rejections))
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ public abstract class ScriptContext
|
||||
/// dependency set is required for the change-driven scheduler to subscribe to the
|
||||
/// right upstream tags at load time.
|
||||
/// </remarks>
|
||||
/// <param name="path">The literal tag path to read.</param>
|
||||
public abstract DataValueSnapshot GetTag(string path);
|
||||
|
||||
/// <summary>
|
||||
@@ -53,6 +54,8 @@ public abstract class ScriptContext
|
||||
/// extractor tracks the write targets so the engine knows what downstream
|
||||
/// subscribers to notify.
|
||||
/// </remarks>
|
||||
/// <param name="path">The literal tag path to write to.</param>
|
||||
/// <param name="value">The value to write to the virtual tag.</param>
|
||||
public abstract void SetVirtualTag(string path, object? value);
|
||||
|
||||
/// <summary>
|
||||
@@ -75,6 +78,9 @@ public abstract class ScriptContext
|
||||
/// Useful for alarm predicates that shouldn't flicker on small noise. Pure
|
||||
/// function; no side effects.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public static bool Deadband(double current, double previous, double tolerance)
|
||||
=> Math.Abs(current - previous) > tolerance;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
_func = func;
|
||||
}
|
||||
|
||||
/// <summary>Compiles user script source into an evaluator.</summary>
|
||||
/// <param name="scriptSource">The user script source code to compile.</param>
|
||||
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
||||
{
|
||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||
@@ -168,7 +170,9 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
return new ScriptEvaluator<TContext, TResult>(alc, func);
|
||||
}
|
||||
|
||||
/// <summary>Run against an already-constructed context.</summary>
|
||||
/// <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>
|
||||
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptEvaluator<TContext, TResult>));
|
||||
@@ -384,10 +388,13 @@ public sealed class ScriptEvaluator<TContext, TResult> : IDisposable
|
||||
/// </summary>
|
||||
internal sealed class ScriptAssemblyLoadContext : AssemblyLoadContext
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptAssemblyLoadContext"/> class.</summary>
|
||||
/// <param name="name">The name of the assembly load context.</param>
|
||||
public ScriptAssemblyLoadContext(string name) : base(name, isCollectible: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Assembly? Load(AssemblyName assemblyName) => null;
|
||||
}
|
||||
|
||||
@@ -400,8 +407,11 @@ internal sealed class ScriptAssemblyLoadContext : AssemblyLoadContext
|
||||
/// </summary>
|
||||
public sealed class CompilationErrorException : Exception
|
||||
{
|
||||
/// <summary>Gets the compilation diagnostics that caused the error.</summary>
|
||||
public IReadOnlyList<Diagnostic> Diagnostics { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CompilationErrorException"/> class.</summary>
|
||||
/// <param name="diagnostics">The compilation diagnostics that caused the error.</param>
|
||||
public CompilationErrorException(IReadOnlyList<Diagnostic> diagnostics)
|
||||
: base(BuildMessage(diagnostics))
|
||||
{
|
||||
|
||||
@@ -15,5 +15,6 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
public class ScriptGlobals<TContext>
|
||||
where TContext : ScriptContext
|
||||
{
|
||||
/// <summary>Gets or sets the script context available to scripts.</summary>
|
||||
public TContext ctx { get; set; } = default!;
|
||||
}
|
||||
|
||||
@@ -30,12 +30,17 @@ public sealed class ScriptLogCompanionSink : ILogEventSink
|
||||
private readonly ILogger _mainLogger;
|
||||
private readonly LogEventLevel _minMirrorLevel;
|
||||
|
||||
/// <summary>Initializes a new instance of the ScriptLogCompanionSink.</summary>
|
||||
/// <param name="mainLogger">The companion logger to mirror events to.</param>
|
||||
/// <param name="minMirrorLevel">The minimum log level to mirror (default: Error).</param>
|
||||
public ScriptLogCompanionSink(ILogger mainLogger, LogEventLevel minMirrorLevel = LogEventLevel.Error)
|
||||
{
|
||||
_mainLogger = mainLogger ?? throw new ArgumentNullException(nameof(mainLogger));
|
||||
_minMirrorLevel = minMirrorLevel;
|
||||
}
|
||||
|
||||
/// <summary>Emits a log event to the companion logger if it meets the minimum level.</summary>
|
||||
/// <param name="logEvent">The log event to emit.</param>
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
if (logEvent is null) return;
|
||||
|
||||
@@ -30,6 +30,8 @@ public sealed class ScriptLoggerFactory
|
||||
|
||||
private readonly ILogger _rootLogger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ScriptLoggerFactory"/> class.</summary>
|
||||
/// <param name="rootLogger">The root logger from which per-script loggers are derived.</param>
|
||||
public ScriptLoggerFactory(ILogger rootLogger)
|
||||
{
|
||||
_rootLogger = rootLogger ?? throw new ArgumentNullException(nameof(rootLogger));
|
||||
@@ -39,6 +41,8 @@ public sealed class ScriptLoggerFactory
|
||||
/// Create a per-script logger. Every event it emits carries
|
||||
/// <c>ScriptName=<paramref name="scriptName"/></c> as a structured property.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The name of the script for which to create a logger.</param>
|
||||
/// <returns>An ILogger instance bound with the script name.</returns>
|
||||
public ILogger Create(string scriptName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptName))
|
||||
|
||||
@@ -42,6 +42,7 @@ public static class ScriptSandbox
|
||||
/// subclass the script's <c>ctx</c> will be of — the compiler uses its assembly
|
||||
/// to resolve <c>ctx.GetTag(...)</c> calls.
|
||||
/// </summary>
|
||||
/// <param name="contextType">The concrete script context type to use for compilation.</param>
|
||||
public static SandboxConfig Build(Type contextType)
|
||||
{
|
||||
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
||||
|
||||
@@ -44,11 +44,16 @@ public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||
/// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
|
||||
public TimeSpan Timeout { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the TimedScriptEvaluator class with the default timeout.</summary>
|
||||
/// <param name="inner">The inner script evaluator.</param>
|
||||
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
|
||||
: this(inner, DefaultTimeout)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the TimedScriptEvaluator class with a custom timeout.</summary>
|
||||
/// <param name="inner">The inner script evaluator.</param>
|
||||
/// <param name="timeout">The evaluation timeout duration.</param>
|
||||
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner, TimeSpan timeout)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
@@ -57,6 +62,10 @@ public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||
Timeout = timeout;
|
||||
}
|
||||
|
||||
/// <summary>Runs the script evaluation with the configured timeout.</summary>
|
||||
/// <param name="context">The script context.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The evaluation result.</returns>
|
||||
public async Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||
@@ -97,8 +106,11 @@ public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||
/// </summary>
|
||||
public sealed class ScriptTimeoutException : Exception
|
||||
{
|
||||
/// <summary>Gets the timeout duration that was exceeded.</summary>
|
||||
public TimeSpan Timeout { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the ScriptTimeoutException class.</summary>
|
||||
/// <param name="timeout">The timeout duration that was exceeded.</param>
|
||||
public ScriptTimeoutException(TimeSpan timeout)
|
||||
: base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " +
|
||||
"The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " +
|
||||
|
||||
@@ -48,6 +48,8 @@ public sealed class DependencyGraph
|
||||
/// the same node overwrites the prior dependency set, so re-publishing an edited
|
||||
/// script works without a separate "remove" call.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node identifier.</param>
|
||||
/// <param name="dependsOn">The set of tags this node depends on.</param>
|
||||
public void Add(string nodeId, IReadOnlySet<string> dependsOn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nodeId)) throw new ArgumentException("Node id required.", nameof(nodeId));
|
||||
@@ -74,6 +76,8 @@ public sealed class DependencyGraph
|
||||
}
|
||||
|
||||
/// <summary>Tag paths <paramref name="nodeId"/> directly reads.</summary>
|
||||
/// <param name="nodeId">The node identifier.</param>
|
||||
/// <returns>The set of tags this node directly depends on.</returns>
|
||||
public IReadOnlySet<string> DirectDependencies(string nodeId) =>
|
||||
_dependsOn.TryGetValue(nodeId, out var set) ? set : EmptySet;
|
||||
|
||||
@@ -82,6 +86,8 @@ public sealed class DependencyGraph
|
||||
/// <paramref name="nodeId"/> changes, these need to re-evaluate. Direct only;
|
||||
/// transitive propagation falls out of the topological sort.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node identifier.</param>
|
||||
/// <returns>The set of tags that directly depend on this node.</returns>
|
||||
public IReadOnlySet<string> DirectDependents(string nodeId) =>
|
||||
_dependents.TryGetValue(nodeId, out var set) ? set : EmptySet;
|
||||
|
||||
@@ -91,6 +97,8 @@ public sealed class DependencyGraph
|
||||
/// change-trigger dispatcher to schedule the right sequence of re-evaluations
|
||||
/// when a single upstream value changes.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node identifier.</param>
|
||||
/// <returns>The list of all transitive dependents in topological order.</returns>
|
||||
public IReadOnlyList<string> TransitiveDependentsInOrder(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nodeId)) return [];
|
||||
@@ -270,6 +278,9 @@ public sealed class DependencyGraph
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all nodes and dependencies from the graph.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_dependsOn.Clear();
|
||||
@@ -281,8 +292,15 @@ public sealed class DependencyGraph
|
||||
/// <summary>Thrown when <see cref="DependencyGraph.TopologicalSort"/> finds one or more cycles.</summary>
|
||||
public sealed class DependencyCycleException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cycles detected in the dependency graph.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<string>> Cycles { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DependencyCycleException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="cycles">The cycles detected in the dependency graph.</param>
|
||||
public DependencyCycleException(IReadOnlyList<IReadOnlyList<string>> cycles)
|
||||
: base(BuildMessage(cycles))
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
/// </remarks>
|
||||
public interface IHistoryWriter
|
||||
{
|
||||
/// <summary>Records a data value snapshot for the given path.</summary>
|
||||
/// <param name="path">The virtual tag path.</param>
|
||||
/// <param name="value">The data value snapshot to record.</param>
|
||||
void Record(string path, DataValueSnapshot value);
|
||||
}
|
||||
|
||||
@@ -21,5 +24,9 @@ public interface IHistoryWriter
|
||||
public sealed class NullHistoryWriter : IHistoryWriter
|
||||
{
|
||||
public static readonly NullHistoryWriter Instance = new();
|
||||
|
||||
/// <summary>Records a data value snapshot (no-op implementation).</summary>
|
||||
/// <param name="path">The virtual tag path (unused).</param>
|
||||
/// <param name="value">The data value snapshot (unused).</param>
|
||||
public void Record(string path, DataValueSnapshot value) { }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public interface ITagUpstreamSource
|
||||
/// <paramref name="path"/>. Returns a <c>BadNodeIdUnknown</c>-quality snapshot
|
||||
/// when the path isn't configured.
|
||||
/// </summary>
|
||||
/// <param name="path">The tag path to read.</param>
|
||||
DataValueSnapshot ReadTag(string path);
|
||||
|
||||
/// <summary>
|
||||
@@ -37,5 +38,7 @@ public interface ITagUpstreamSource
|
||||
/// engine disposes when the virtual-tag config is reloaded or the engine shuts
|
||||
/// down, so source-side subscriptions don't leak.
|
||||
/// </summary>
|
||||
/// <param name="path">The tag path to subscribe to.</param>
|
||||
/// <param name="observer">The callback to invoke when the value changes.</param>
|
||||
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ public sealed class TimerTriggerScheduler : IDisposable
|
||||
private long _skippedTickCount;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="TimerTriggerScheduler"/> class.</summary>
|
||||
/// <param name="engine">The virtual tag engine to trigger evaluations on.</param>
|
||||
/// <param name="logger">A logger for diagnostic output.</param>
|
||||
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
@@ -48,6 +51,7 @@ public sealed class TimerTriggerScheduler : IDisposable
|
||||
/// group in topological order so cascades are consistent with change-triggered
|
||||
/// behavior.
|
||||
/// </summary>
|
||||
/// <param name="definitions">The virtual tag definitions to schedule timers for.</param>
|
||||
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
|
||||
@@ -116,6 +120,7 @@ public sealed class TimerTriggerScheduler : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -130,14 +135,20 @@ public sealed class TimerTriggerScheduler : IDisposable
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Groups tags that share the same timer interval.</summary>
|
||||
private sealed class TickGroup
|
||||
{
|
||||
// 0 = idle, 1 = a tick is currently running (or queued) for this group. Use
|
||||
// Interlocked.CompareExchange so a timer callback observes a consistent "is the
|
||||
// prior tick still running" answer without taking a lock.
|
||||
/// <summary>Flag indicating whether a tick is currently in flight for this group.</summary>
|
||||
public int InFlight;
|
||||
|
||||
/// <summary>Gets the list of tag paths in this group.</summary>
|
||||
public IReadOnlyList<string> Paths { get; }
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="TickGroup"/> class.</summary>
|
||||
/// <param name="paths">The tag paths in this timer group.</param>
|
||||
public TickGroup(IReadOnlyList<string> paths)
|
||||
{
|
||||
Paths = paths;
|
||||
|
||||
@@ -31,6 +31,11 @@ public sealed class VirtualTagContext : ScriptContext
|
||||
private readonly Action<string, object?> _setVirtualTag;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
/// <summary>Initializes a new instance of the VirtualTagContext class.</summary>
|
||||
/// <param name="readCache">The cache of tag values available for reading during script execution.</param>
|
||||
/// <param name="setVirtualTag">Callback to invoke when the script writes a virtual tag value.</param>
|
||||
/// <param name="logger">The Serilog logger instance.</param>
|
||||
/// <param name="clock">Optional function to get the current UTC time; defaults to DateTime.UtcNow if not provided.</param>
|
||||
public VirtualTagContext(
|
||||
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
|
||||
Action<string, object?> setVirtualTag,
|
||||
@@ -43,6 +48,7 @@ public sealed class VirtualTagContext : ScriptContext
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
@@ -52,6 +58,7 @@ public sealed class VirtualTagContext : ScriptContext
|
||||
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
@@ -59,7 +66,9 @@ public sealed class VirtualTagContext : ScriptContext
|
||||
_setVirtualTag(path, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTime Now => _clock();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ILogger Logger { get; }
|
||||
}
|
||||
|
||||
@@ -60,6 +60,15 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
private bool _loaded;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VirtualTagEngine"/> class.
|
||||
/// </summary>
|
||||
/// <param name="upstream">The upstream tag source for reading input tags.</param>
|
||||
/// <param name="loggerFactory">Factory for creating script loggers.</param>
|
||||
/// <param name="engineLogger">Logger for engine events and errors.</param>
|
||||
/// <param name="historyWriter">Optional history writer for recording tag evaluations; defaults to no-op.</param>
|
||||
/// <param name="clock">Optional function providing current UTC time; defaults to <see cref="DateTime.UtcNow"/>.</param>
|
||||
/// <param name="scriptTimeout">Optional timeout for script evaluation; defaults to <see cref="TimedScriptEvaluator{TContext, TResult}.DefaultTimeout"/>.</param>
|
||||
public VirtualTagEngine(
|
||||
ITagUpstreamSource upstream,
|
||||
ScriptLoggerFactory loggerFactory,
|
||||
@@ -80,6 +89,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
public IReadOnlyCollection<string> LoadedTagPaths => _tags.Keys;
|
||||
|
||||
/// <summary>Compile + register every tag in <paramref name="definitions"/>. Throws on cycle or any compile failure.</summary>
|
||||
/// <param name="definitions">List of virtual tag definitions to load and compile.</param>
|
||||
public void Load(IReadOnlyList<VirtualTagDefinition> definitions)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(VirtualTagEngine));
|
||||
@@ -187,6 +197,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// virtual tags have a defined initial value rather than inheriting the cache
|
||||
/// default. Also called after a config reload.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token to stop evaluation.</param>
|
||||
public async Task EvaluateAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
EnsureLoaded();
|
||||
@@ -199,6 +210,8 @@ 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>
|
||||
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
EnsureLoaded();
|
||||
@@ -212,6 +225,7 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// tags return the last-known upstream value; virtual tags return their last
|
||||
/// evaluation result.
|
||||
/// </summary>
|
||||
/// <param name="path">Path of the tag to read.</param>
|
||||
public DataValueSnapshot Read(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
@@ -226,6 +240,8 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// Returns an <see cref="IDisposable"/> to unsubscribe. Does NOT fire a seed
|
||||
/// value — subscribers call <see cref="Read"/> for the current value if needed.
|
||||
/// </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>
|
||||
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
|
||||
@@ -258,6 +274,8 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
/// change too if they subscribed via the engine), and schedules every
|
||||
/// change-triggered dependent for re-evaluation in topological order.
|
||||
/// </summary>
|
||||
/// <param name="path">Path of the upstream tag that changed.</param>
|
||||
/// <param name="value">New value of the tag.</param>
|
||||
internal void OnUpstreamChange(string path, DataValueSnapshot value)
|
||||
{
|
||||
_valueCache[path] = value;
|
||||
@@ -495,6 +513,9 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
"VirtualTagEngine not loaded. Call Load(definitions) first.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases all resources held by the engine, including upstream subscriptions and compiled script caches.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -507,6 +528,9 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
_compileCache.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dependency graph for testing purposes.
|
||||
/// </summary>
|
||||
internal DependencyGraph GraphForTesting => _graph;
|
||||
|
||||
private sealed class Unsub : IDisposable
|
||||
@@ -514,10 +538,19 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
private readonly VirtualTagEngine _engine;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Unsub"/> class.
|
||||
/// </summary>
|
||||
/// <param name="e">The parent virtual tag engine.</param>
|
||||
/// <param name="path">Path of the subscribed tag.</param>
|
||||
/// <param name="observer">The observer callback to unsubscribe.</param>
|
||||
public Unsub(VirtualTagEngine e, string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
_engine = e; _path = path; _observer = observer;
|
||||
}
|
||||
/// <summary>
|
||||
/// Removes the observer from the subscription list.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_engine._observers.TryGetValue(_path, out var list))
|
||||
|
||||
@@ -23,13 +23,26 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
private readonly VirtualTagEngine _engine;
|
||||
private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VirtualTagSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="engine">The virtual tag engine.</param>
|
||||
public VirtualTagSource(VirtualTagEngine engine)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when data values change.
|
||||
/// </summary>
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
/// <summary>
|
||||
/// Reads data values asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The full references to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A list of data value snapshots.</returns>
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -40,6 +53,13 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to data changes asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="fullReferences">The full references to subscribe to.</param>
|
||||
/// <param name="publishingInterval">The publishing interval.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A subscription handle.</returns>
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
TimeSpan publishingInterval,
|
||||
@@ -67,6 +87,12 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from data changes asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="handle">The subscription handle.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
||||
@@ -82,7 +108,15 @@ public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
|
||||
private sealed class SubscriptionHandle : ISubscriptionHandle
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubscriptionHandle"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The subscription identifier.</param>
|
||||
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the diagnostic identifier.
|
||||
/// </summary>
|
||||
public string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,15 @@ public sealed record AuthorizationDecision(
|
||||
AuthorizationVerdict Verdict,
|
||||
IReadOnlyList<MatchedGrant> Provenance)
|
||||
{
|
||||
/// <summary>Gets a value indicating whether the authorization decision allows the operation.</summary>
|
||||
public bool IsAllowed => Verdict == AuthorizationVerdict.Allow;
|
||||
|
||||
/// <summary>Convenience constructor for the common "no grants matched" outcome.</summary>
|
||||
public static AuthorizationDecision NotGranted() => new(AuthorizationVerdict.NotGranted, []);
|
||||
|
||||
/// <summary>Allow with the list of grants that matched.</summary>
|
||||
/// <param name="provenance">The list of grants that matched to allow the operation.</param>
|
||||
/// <returns>An authorization decision with Allow verdict.</returns>
|
||||
public static AuthorizationDecision Allowed(IReadOnlyList<MatchedGrant> provenance)
|
||||
=> new(AuthorizationVerdict.Allow, provenance);
|
||||
}
|
||||
|
||||
@@ -19,5 +19,8 @@ public interface IPermissionEvaluator
|
||||
/// failure to <c>BadUserAccessDenied</c> per OPC UA Part 4 when the result is not
|
||||
/// <see cref="AuthorizationVerdict.Allow"/>.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed class PermissionTrie
|
||||
/// session's <paramref name="ldapGroups"/>. Returns the matched-grant list; the caller
|
||||
/// OR-s the flag bits to decide whether the requested permission is carried.
|
||||
/// </summary>
|
||||
/// <param name="scope">The node scope to match permissions for.</param>
|
||||
/// <param name="ldapGroups">The user's LDAP group memberships.</param>
|
||||
public IReadOnlyList<MatchedGrant> CollectMatches(NodeScope scope, IEnumerable<string> ldapGroups)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class PermissionTrieCache
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Install a trie for a cluster + make it the current generation.</summary>
|
||||
/// <param name="trie">The permission trie to install.</param>
|
||||
public void Install(PermissionTrie trie)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trie);
|
||||
@@ -32,6 +33,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>
|
||||
public PermissionTrie? GetTrie(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
@@ -39,6 +41,8 @@ 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>
|
||||
public PermissionTrie? GetTrie(string clusterId, long generationId)
|
||||
{
|
||||
if (!_byCluster.TryGetValue(clusterId, out var entry)) return null;
|
||||
@@ -46,10 +50,12 @@ 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>
|
||||
public long? CurrentGenerationId(string clusterId)
|
||||
=> _byCluster.TryGetValue(clusterId, out var entry) ? entry.Current.GenerationId : null;
|
||||
|
||||
/// <summary>Drop every cached trie for one cluster.</summary>
|
||||
/// <param name="clusterId">The cluster identifier.</param>
|
||||
public void Invalidate(string clusterId) => _byCluster.TryRemove(clusterId, out _);
|
||||
|
||||
/// <summary>
|
||||
@@ -59,6 +65,8 @@ public sealed class PermissionTrieCache
|
||||
/// class-typed entry) so a concurrent <see cref="Install"/> on the same cluster is never
|
||||
/// silently overwritten.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The cluster identifier.</param>
|
||||
/// <param name="keepLatest">The number of most recent generations to retain.</param>
|
||||
public void Prune(string clusterId, int keepLatest = 3)
|
||||
{
|
||||
if (keepLatest < 1) throw new ArgumentOutOfRangeException(nameof(keepLatest), keepLatest, "keepLatest must be >= 1");
|
||||
@@ -96,12 +104,18 @@ public sealed class PermissionTrieCache
|
||||
// Class (not record) so TryUpdate in Prune uses reference equality for the CAS comparison.
|
||||
private sealed class ClusterEntry(PermissionTrie current, IReadOnlyDictionary<long, PermissionTrie> tries)
|
||||
{
|
||||
/// <summary>Gets the current (latest) permission trie for the cluster.</summary>
|
||||
public PermissionTrie Current { get; } = current;
|
||||
/// <summary>Gets all cached tries for the cluster keyed by generation ID.</summary>
|
||||
public IReadOnlyDictionary<long, PermissionTrie> Tries { get; } = tries;
|
||||
|
||||
/// <summary>Creates a cluster entry from a single trie.</summary>
|
||||
/// <param name="trie">The permission trie to create the entry from.</param>
|
||||
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>
|
||||
public ClusterEntry WithAdditional(PermissionTrie trie)
|
||||
{
|
||||
var next = new Dictionary<long, PermissionTrie>(Tries) { [trie.GenerationId] = trie };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user