docs(xmldoc): fill missing XML docs + strip tracking-ID comments across src
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes
project bookkeeping IDs (task/tracking refs) from shipped code comments,
so the docs read cleanly and CommentChecker is quiet except for known
false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc).
Doc/comment-only; no logic changed; solution builds clean.
This commit is contained in:
Joseph Doherty
2026-07-07 12:38:39 -04:00
parent 384dbd7d36
commit 9cad9ed0fc
375 changed files with 1899 additions and 2493 deletions
@@ -38,6 +38,8 @@ public sealed class BrowseSessionReaper(
/// <summary>Evicts every session whose <see cref="Commons.Browsing.IBrowseSession.LastUsedUtc"/>
/// is older than <see cref="IdleTtl"/>. Internal so tests can drive a tick directly.</summary>
/// <param name="ct">Cancellation token for the reap pass.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
internal async Task ReapOnceAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
@@ -14,17 +14,25 @@ public sealed class BrowseSessionRegistry
private readonly ConcurrentDictionary<Guid, IBrowseSession> _sessions = new();
/// <summary>Adds (or replaces) a session in the registry keyed by its token.</summary>
/// <param name="session">The session to register.</param>
public void Register(IBrowseSession session) => _sessions[session.Token] = session;
/// <summary>Looks up a session by token without removing it.</summary>
/// <param name="token">The token identifying the session to look up.</param>
/// <param name="session">The found session, if any.</param>
/// <returns><c>true</c> if a session with the given token was found.</returns>
public bool TryGet(Guid token, out IBrowseSession session) =>
_sessions.TryGetValue(token, out session!);
/// <summary>Atomically removes a session from the registry, returning it for disposal.</summary>
/// <param name="token">The token identifying the session to remove.</param>
/// <param name="session">The removed session, if found.</param>
/// <returns><c>true</c> if a session with the given token was found and removed.</returns>
public bool TryRemove(Guid token, out IBrowseSession session) =>
_sessions.TryRemove(token, out session!);
/// <summary>Returns a point-in-time snapshot of all currently registered sessions.</summary>
/// <returns>The registered sessions and their tokens at the time of the call.</returns>
public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() =>
_sessions.Select(kv => (kv.Key, kv.Value)).ToList();
}
@@ -21,21 +21,38 @@ public interface IBrowserSessionService
{
/// <summary>Opens a session against the named driver type using the given JSON config.
/// Never throws — all errors are surfaced via <see cref="BrowseOpenResult"/>.</summary>
/// <param name="driverType">The driver type to open a browse session against.</param>
/// <param name="configJson">The driver configuration as JSON.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The result of opening the session, including the registry token on success.</returns>
Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
/// <summary>Returns the root nodes of an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The top-level browse nodes.</returns>
Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct);
/// <summary>Returns the direct children of <paramref name="nodeId"/> in an open session.
/// Throws <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="nodeId">The node whose direct children are returned.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The direct children of the node.</returns>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Returns the attributes of <paramref name="nodeId"/> in an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="nodeId">The node whose attributes are returned.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
/// <param name="token">Registry handle for the browse session to close.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task CloseAsync(Guid token);
}
@@ -10,8 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
public sealed record CertActionResult(bool Success, string? Error)
{
/// <summary>A successful result.</summary>
/// <returns>A <see cref="CertActionResult"/> indicating success.</returns>
public static CertActionResult Ok() => new(true, null);
/// <summary>A failed result carrying the given error message.</summary>
/// <param name="error">The friendly error message describing the failure.</param>
/// <returns>A <see cref="CertActionResult"/> indicating failure with <paramref name="error"/>.</returns>
public static CertActionResult Fail(string error) => new(false, error);
}
@@ -25,10 +25,7 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
_proxy = registry.Get<AdminOperationsActorKey>();
}
/// <summary>Starts a deployment via the admin operations actor.</summary>
/// <param name="createdBy">The username of who initiated the deployment.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The deployment start result.</returns>
/// <inheritdoc />
public async Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct)
{
var msg = new StartDeployment(createdBy, CorrelationId.NewId());
@@ -37,12 +34,7 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
return await _proxy.Ask<StartDeploymentResult>(msg, AskTimeout, linked.Token);
}
/// <summary>Acknowledges one alarm via the admin singleton.</summary>
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
/// <param name="user">The acting operator's name.</param>
/// <param name="comment">Optional free-text comment; null when none.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The acknowledge result.</returns>
/// <inheritdoc />
public async Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(
string alarmId, string user, string? comment, CancellationToken ct)
{
@@ -52,14 +44,7 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
return await _proxy.Ask<AcknowledgeAlarmResult>(msg, AskTimeout, linked.Token);
}
/// <summary>Shelves or unshelves one alarm via the admin singleton.</summary>
/// <param name="alarmId">The alarm's ScriptedAlarmId.</param>
/// <param name="user">The acting operator's name.</param>
/// <param name="kind">Which shelve action to perform.</param>
/// <param name="unshelveAtUtc">For a timed shelve, when it expires; null otherwise.</param>
/// <param name="comment">Optional free-text comment; null when none.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>The shelve result.</returns>
/// <inheritdoc />
public async Task<ShelveAlarmResult> ShelveAlarmAsync(
string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc, string? comment, CancellationToken ct)
{
@@ -69,11 +54,7 @@ public sealed class AdminOperationsClient : IAdminOperationsClient
return await _proxy.Ask<ShelveAlarmResult>(msg, AskTimeout, linked.Token);
}
/// <summary>
/// Generic Ask — forwards any message to the AdminOperationsActor singleton proxy.
/// Uses the caller-supplied <paramref name="ct"/> for cancellation; does not impose an
/// additional internal timeout beyond what the proxy itself enforces.
/// </summary>
/// <inheritdoc />
public Task<T> AskAsync<T>(object message, CancellationToken ct)
=> _proxy.Ask<T>(message, cancellationToken: ct);
}
@@ -25,6 +25,7 @@ public sealed class AdminProbeService
/// <param name="configJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
/// <param name="timeoutSeconds">Per-probe timeout; actor clamps to [1, 60].</param>
/// <param name="ct">Optional cancellation token from the caller.</param>
/// <returns>The probe result, or a timeout failure if no reply arrives in time.</returns>
public async Task<TestDriverConnectResult> TestAsync(
string driverType,
string configJson,
@@ -31,10 +31,7 @@ public sealed class FleetDiagnosticsClient : IFleetDiagnosticsClient
_systemName = options.Value.SystemName;
}
/// <summary>Gets diagnostics for a cluster node.</summary>
/// <param name="nodeId">The node identifier to query.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Diagnostics snapshot for the node, or an empty snapshot if the query fails.</returns>
/// <inheritdoc />
public async Task<NodeDiagnosticsSnapshot> GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct)
{
var selection = _system.ActorSelection($"akka.tcp://{_systemName}@{nodeId.Value}/user/driver-host");
@@ -10,6 +10,7 @@ public static class ServiceCollectionExtensions
/// Registers the Admin UI client services in the dependency injection container.
/// </summary>
/// <param name="services">The service collection to register clients into.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaAdminClients(this IServiceCollection services)
{
services.AddScoped<IAdminOperationsClient, AdminOperationsClient>();
@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// </summary>
public static class AbLegacyAddressBuilder
{
/// <summary>Builds the canonical AB Legacy address string from its file-type, file-number, and element parts.</summary>
/// <param name="fileType">The file-type prefix (e.g. <c>N</c>).</param>
/// <param name="fileNumber">The file number.</param>
/// <param name="element">The element index within the file.</param>
/// <returns>The canonical address string (e.g. <c>N7:0</c>).</returns>
public static string Build(string fileType, int fileNumber, int element)
=> $"{fileType}{fileNumber}:{element}";
}
@@ -7,6 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// </summary>
public static class FocasAddressBuilder
{
/// <summary>Builds the canonical FOCAS address string from a parameter group and parameter ID.</summary>
/// <param name="group">The FOCAS parameter group (e.g. <c>"axis"</c>).</param>
/// <param name="parameterId">The parameter ID within the group.</param>
/// <returns>The canonical address string in <c>group:parameterId</c> form (e.g. <c>axis:5</c>).</returns>
public static string Build(string group, int parameterId)
=> $"{group}:{parameterId}";
}
@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// </summary>
public static class ModbusAddressBuilder
{
/// <summary>Builds the canonical Modbus address string for a register type, offset, and length.</summary>
/// <param name="regType">The register type (e.g. Coil, DiscreteInput, Input, Holding).</param>
/// <param name="offset">The zero-based register/coil offset.</param>
/// <param name="length">The number of registers/coils spanned.</param>
/// <returns>The canonical address string (e.g. <c>4x00001-1</c>).</returns>
public static string Build(string regType, int offset, int length)
{
var prefix = regType switch
@@ -7,10 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// </summary>
public static class S7AddressBuilder
{
/// <summary>Builds the canonical S7 address string for the given area, DB number, offset, and data type.</summary>
/// <param name="area">DB / M / I / Q</param>
/// <param name="dbNumber">Only relevant when area == "DB".</param>
/// <param name="offset">Byte offset (decimal).</param>
/// <param name="s7Type">X / B / W / D / REAL</param>
/// <returns>The canonical S7 address string (e.g. <c>DB10.DBD20:REAL</c>).</returns>
public static string Build(string area, int dbNumber, int offset, string s7Type)
{
if (area == "DB")
@@ -14,19 +14,28 @@ public sealed class ResilienceFormModel
public static readonly string[] Capabilities =
["Read", "Write", "Discover", "Subscribe", "Probe", "AlarmSubscribe", "AlarmAcknowledge", "HistoryRead"];
/// <summary>Gets or sets the bulkhead max-concurrency override; null = use the tier default.</summary>
public int? BulkheadMaxConcurrent { get; set; }
/// <summary>Gets or sets the bulkhead max-queue-length override; null = use the tier default.</summary>
public int? BulkheadMaxQueue { get; set; }
/// <summary>Gets or sets the driver recycle-interval override, in seconds; null = use the tier default.</summary>
public int? RecycleIntervalSeconds { get; set; }
// capability name -> (timeout, retry, breaker), each nullable.
/// <summary>Gets or sets the per-capability resilience overrides, keyed by capability name.</summary>
public Dictionary<string, CapabilityRow> Policies { get; set; } =
Capabilities.ToDictionary(c => c, _ => new CapabilityRow(), StringComparer.OrdinalIgnoreCase);
/// <summary>Per-capability timeout/retry/breaker override row; null fields fall back to the tier default.</summary>
public sealed class CapabilityRow
{
/// <summary>Gets or sets the timeout override, in seconds; null = use the tier default.</summary>
public int? TimeoutSeconds { get; set; }
/// <summary>Gets or sets the retry-count override; null = use the tier default.</summary>
public int? RetryCount { get; set; }
/// <summary>Gets or sets the circuit-breaker failure-threshold override; null = use the tier default.</summary>
public int? BreakerFailureThreshold { get; set; }
/// <summary>Gets a value indicating whether all fields in this row are unset.</summary>
public bool IsEmpty => TimeoutSeconds is null && RetryCount is null && BreakerFailureThreshold is null;
}
@@ -37,6 +46,9 @@ public sealed class ResilienceFormModel
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Parses the override JSON into a form model; malformed or blank JSON yields an empty (all-default) form.</summary>
/// <param name="json">The raw resilience-override JSON, or null/blank if there is no override.</param>
/// <returns>A populated form model, or an all-default one when <paramref name="json"/> is blank or malformed.</returns>
public static ResilienceFormModel FromJson(string? json)
{
var model = new ResilienceFormModel();
@@ -62,6 +74,7 @@ public sealed class ResilienceFormModel
}
/// <summary>Emit only the non-null overrides; returns null when nothing is overridden.</summary>
/// <returns>The serialized override JSON, or null when no field in this form is overridden.</returns>
public string? ToJson()
{
var caps = Policies
@@ -87,18 +100,27 @@ public sealed class ResilienceFormModel
return JsonSerializer.Serialize(shape, WriteOpts);
}
/// <summary>Wire shape of the resilience-override JSON, as consumed by DriverResilienceOptionsParser.</summary>
private sealed class Shape
{
/// <summary>Gets or sets the bulkhead max-concurrency override.</summary>
public int? BulkheadMaxConcurrent { get; set; }
/// <summary>Gets or sets the bulkhead max-queue-length override.</summary>
public int? BulkheadMaxQueue { get; set; }
/// <summary>Gets or sets the driver recycle-interval override, in seconds.</summary>
public int? RecycleIntervalSeconds { get; set; }
/// <summary>Gets or sets the per-capability overrides, keyed by capability name.</summary>
public Dictionary<string, PolicyShape>? CapabilityPolicies { get; set; }
}
/// <summary>Wire shape of a single capability's timeout/retry/breaker override.</summary>
private sealed class PolicyShape
{
/// <summary>Gets or sets the timeout override, in seconds.</summary>
public int? TimeoutSeconds { get; set; }
/// <summary>Gets or sets the retry-count override.</summary>
public int? RetryCount { get; set; }
/// <summary>Gets or sets the circuit-breaker failure-threshold override.</summary>
public int? BreakerFailureThreshold { get; set; }
}
}
@@ -19,6 +19,7 @@ public static class EndpointRouteBuilderExtensions
/// </summary>
/// <typeparam name="TApp">The root component type for Razor pages.</typeparam>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The same endpoint route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapAdminUI<TApp>(this IEndpointRouteBuilder app)
where TApp : IComponent
{
@@ -34,6 +35,7 @@ public static class EndpointRouteBuilderExtensions
/// Adds AdminUI services to the dependency injection container.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddAdminUI(this IServiceCollection services)
{
services.AddRazorComponents().AddInteractiveServerComponents();
@@ -25,6 +25,7 @@ public sealed class AlertSignalRBridge : ReceiveActor
/// </summary>
/// <param name="hub">The SignalR hub context to send alerts to.</param>
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Alerts page.</param>
/// <returns>The Akka <see cref="Akka.Actor.Props"/> for creating this actor.</returns>
public static Props Props(IHubContext<AlertHub> hub, IInProcessBroadcaster<AlarmTransitionEvent> broadcaster) =>
Akka.Actor.Props.Create(() => new AlertSignalRBridge(hub, broadcaster));
@@ -28,6 +28,8 @@ public sealed class DriverStatusHub : Hub
/// the current snapshot from the in-memory store so the client renders state without
/// waiting for the next change event.
/// </summary>
/// <param name="driverInstanceId">Identifier of the driver instance to join.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task JoinDriver(string driverInstanceId)
{
var groupName = GroupName(driverInstanceId);
@@ -40,5 +42,7 @@ public sealed class DriverStatusHub : Hub
}
/// <summary>Builds the SignalR group name for a given driver instance.</summary>
/// <param name="driverInstanceId">Identifier of the driver instance.</param>
/// <returns>The SignalR group name for the driver instance.</returns>
public static string GroupName(string driverInstanceId) => $"driver:{driverInstanceId}";
}
@@ -24,6 +24,7 @@ public sealed class DriverStatusSignalRBridge : ReceiveActor
/// <summary>Creates actor props for a <see cref="DriverStatusSignalRBridge"/>.</summary>
/// <param name="hub">The SignalR hub context for pushing snapshots to grouped clients.</param>
/// <param name="store">Snapshot store updated before each SignalR push.</param>
/// <returns>An Akka.NET <see cref="Props"/> instance for spawning the bridge actor.</returns>
public static Props Props(IHubContext<DriverStatusHub> hub, IDriverStatusSnapshotStore store) =>
Akka.Actor.Props.Create(() => new DriverStatusSignalRBridge(hub, store));
@@ -10,6 +10,7 @@ public static class HubRouteBuilderExtensions
/// Maps all OtOpcUa Admin UI SignalR hubs to their configured endpoints.
/// </summary>
/// <param name="app">The endpoint route builder to register hubs on.</param>
/// <returns>The same endpoint route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapOtOpcUaHubs(this IEndpointRouteBuilder app)
{
app.MapHub<FleetStatusHub>(FleetStatusHub.Endpoint);
@@ -24,6 +24,7 @@ public static class HubServiceCollectionExtensions
/// </list>
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaDriverStatusServices(this IServiceCollection services)
{
services.AddSingleton<IDriverStatusSnapshotStore, InMemoryDriverStatusSnapshotStore>();
@@ -46,6 +47,7 @@ public static class HubServiceCollectionExtensions
/// </code>
/// </summary>
/// <param name="builder">The Akka configuration builder.</param>
/// <returns>The same builder, for chaining.</returns>
public static AkkaConfigurationBuilder WithOtOpcUaSignalRBridges(this AkkaConfigurationBuilder builder)
{
builder.WithActors((system, registry, resolver) =>
@@ -11,10 +11,18 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
/// </summary>
public interface IDriverStatusSnapshotStore
{
/// <summary>Stores or replaces the last-known health snapshot for a driver instance.</summary>
/// <param name="snapshot">The driver health snapshot to store.</param>
void Upsert(DriverHealthChanged snapshot);
/// <summary>Attempts to retrieve the last-known health snapshot for a driver instance.</summary>
/// <param name="driverInstanceId">The driver instance's stable logical ID.</param>
/// <param name="snapshot">The stored snapshot, if found.</param>
/// <returns><see langword="true"/> if a snapshot was found; otherwise <see langword="false"/>.</returns>
bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot);
/// <summary>Returns a point-in-time snapshot of every driver instance's last-known health.</summary>
/// <returns>A read-only collection of the last-known health snapshot for each driver instance.</returns>
IReadOnlyCollection<DriverHealthChanged> GetAll();
/// <summary>
@@ -25,6 +25,7 @@ public interface IInProcessBroadcaster<T>
event Action<T>? Received;
/// <summary>Fan the item out to all current <see cref="Received"/> subscribers.</summary>
/// <param name="item">The item to publish to subscribers.</param>
void Publish(T item);
/// <summary>
@@ -21,6 +21,7 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor
/// <summary>Creates a Props instance for the ScriptLogSignalRBridge.</summary>
/// <param name="hub">The SignalR hub context for sending messages to clients.</param>
/// <param name="broadcaster">In-process fan-out read directly by the Blazor Server Script log page.</param>
/// <returns>A configured <see cref="Akka.Actor.Props"/> for creating the actor.</returns>
public static Props Props(IHubContext<ScriptLogHub> hub, IInProcessBroadcaster<ScriptLogEntry> broadcaster) =>
Akka.Actor.Props.Create(() => new ScriptLogSignalRBridge(hub, broadcaster));
@@ -14,9 +14,13 @@ public interface IScriptTagCatalog
/// ctx.GetTag/SetVirtualTag), optionally filtered by a literal prefix.</summary>
/// <param name="filter">Case-insensitive StartsWith prefix; <c>null</c>/empty returns all (bounded).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The matching configured tag + virtual-tag paths.</returns>
Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct);
/// <summary>Exact (case-sensitive, Ordinal) lookup of a resolvable tag path; null when not a known configured path.</summary>
/// <param name="path">The exact tag/virtual-tag path to look up.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The resolved tag info, or <see langword="null"/> when <paramref name="path"/> is not a known configured path.</returns>
Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);
/// <summary>Distinct attribute leaf names — the substring after the first dot of each
@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public static class ScriptAnalysisEndpoints
{
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated to the Administrator/Designer roles.</summary>
/// <param name="endpoints">The route builder to map the endpoints onto.</param>
/// <returns>The same route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
// Require Administrator or Designer — matches the Script page gate and the /deployments gate.
@@ -33,6 +33,9 @@ public sealed class ScriptAnalysisService
private readonly IScriptTagCatalog? _catalog;
private readonly ILogger<ScriptAnalysisService>? _logger;
/// <summary>Creates the script-analysis service.</summary>
/// <param name="catalog">The tag catalog used to resolve tag-path completions/hover, or null to disable tag-path features.</param>
/// <param name="logger">The logger used to record analysis failures, or null to disable logging.</param>
public ScriptAnalysisService(IScriptTagCatalog? catalog = null, ILogger<ScriptAnalysisService>? logger = null)
{
_catalog = catalog;
@@ -81,6 +84,10 @@ public sealed class ScriptAnalysisService
private static string Normalize(string? s) => (s ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
/// <summary>Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
/// (forbidden types, disallowed dynamic tag paths).</summary>
/// <param name="req">The diagnose request carrying the script source.</param>
/// <returns>The list of diagnostic markers, empty when the script is clean or analysis fails.</returns>
public DiagnoseResponse Diagnose(DiagnoseRequest req)
{
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
@@ -149,6 +156,9 @@ public sealed class ScriptAnalysisService
if (code[i] == '\n') { line++; col = 1; } else col++;
return (line, col);
}
/// <summary>Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.</summary>
/// <param name="req">The completions request carrying the script source and caret position.</param>
/// <returns>The resolved list of completion items, empty when none apply.</returns>
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
{
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
@@ -245,6 +255,9 @@ public sealed class ScriptAnalysisService
return new CompletionItem(symbol.Name, symbol.Name,
symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), kind);
}
/// <summary>Computes hover information (tag-path info or C# symbol info) for the position under the caret.</summary>
/// <param name="req">The hover request carrying the script source and caret position.</param>
/// <returns>The resolved hover markdown, or a response with a null value when nothing resolves.</returns>
public async Task<HoverResponse> Hover(HoverRequest req)
{
if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null);
@@ -339,6 +352,9 @@ public sealed class ScriptAnalysisService
catch { return null; }
}
/// <summary>Computes signature help for the method invocation at the caret position.</summary>
/// <param name="req">The signature-help request carrying the script source and caret position.</param>
/// <returns>The resolved signature and active-parameter index, or an empty response when none applies.</returns>
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req)
{
var empty = new SignatureHelpResponse(null, null, 0);
@@ -373,7 +389,10 @@ public sealed class ScriptAnalysisService
}
catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; }
}
public FormatResponse Format(FormatRequest req) // Task 8
/// <summary>Formats a script's source code using Roslyn's syntax-tree whitespace normalization.</summary>
/// <param name="req">The format request carrying the script source to format.</param>
/// <returns>The formatted source, or the original source unchanged if parsing/formatting fails.</returns>
public FormatResponse Format(FormatRequest req)
{
if (string.IsNullOrEmpty(req.Code)) return new FormatResponse(req.Code);
try
@@ -389,5 +408,9 @@ public sealed class ScriptAnalysisService
return new FormatResponse(req.Code);
}
}
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>()); // Task 8 (stays empty)
/// <summary>Returns editor inlay hints for the script. Currently a no-op (always empty) — the endpoint
/// exists for future use but the feature is not implemented.</summary>
/// <param name="req">The inlay-hints request (unused).</param>
/// <returns>An always-empty inlay-hints response.</returns>
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>());
}
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// Parameter object carrying the operator-editable fields for an equipment create or update,
/// so <see cref="IUnsTreeService.CreateEquipmentAsync"/> and
/// <see cref="IUnsTreeService.UpdateEquipmentAsync"/> avoid an unwieldy positional signature.
/// The <c>EquipmentId</c> and <c>EquipmentUuid</c> are system-generated (decision #125) and are
/// The <c>EquipmentId</c> and <c>EquipmentUuid</c> are system-generated and are
/// therefore not part of this input. Optional string fields that arrive whitespace-only are
/// collapsed to <c>null</c> by the service.
/// </summary>
@@ -28,7 +28,7 @@ public sealed record LineEditDto(string UnsLineId, string UnsAreaId, string Name
/// An equipment projected for editing: its system-generated id, the operator-editable identity and
/// OPC 40010 identification fields, plus the concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="EquipmentId">The system-generated stable id (read-only — never operator-edited, decision #125).</param>
/// <param name="EquipmentId">The system-generated stable id (read-only — never operator-edited).</param>
/// <param name="Name">UNS level-5 segment name.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">The owning line id (the UNS-line selection).</param>
@@ -54,7 +54,7 @@ public sealed record EquipmentEditDto(string EquipmentId, string Name, string Ma
/// <summary>
/// An equipment-bound tag projected for editing: its operator-editable fields, the owning equipment
/// (so the host can scope the candidate-driver list and refresh the right node), plus the concurrency
/// token the edit modal must echo back on save. Tree tags are always equipment-bound (decision #110),
/// token the edit modal must echo back on save. Tree tags are always equipment-bound,
/// so <c>FolderPath</c> never surfaces here.
/// </summary>
/// <param name="TagId">The tag's stable id (read-only on edit).</param>
@@ -73,7 +73,7 @@ public sealed record TagEditDto(string TagId, string EquipmentId, string Name, s
/// <summary>
/// An equipment-bound virtual tag projected for editing: its operator-editable fields, the owning
/// equipment (so the host can refresh the right node), plus the concurrency token the edit modal must
/// echo back on save. Virtual tags are always scoped to an equipment (plan decision #2), so the modal
/// echo back on save. Virtual tags are always scoped to an equipment, so the modal
/// never offers an equipment-change control.
/// </summary>
/// <param name="VirtualTagId">The virtual tag's stable id (read-only on edit).</param>
@@ -92,7 +92,7 @@ public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId,
/// <summary>
/// The outcome of a bulk equipment CSV import: how many rows were inserted, how many were skipped
/// (existing MachineCode — the importer is additive-only, never an update), and a per-row error list
/// for rows that could not be inserted (unknown line, unknown driver, or a decision-#122 cluster
/// for rows that could not be inserted (unknown line, unknown driver, or a cluster
/// mismatch). Skipped rows never appear in <see cref="Errors"/>.
/// </summary>
/// <param name="Inserted">The count of new Equipment rows added.</param>
@@ -221,7 +221,7 @@ public interface IUnsTreeService
/// <summary>
/// Updates a UNS area's name, notes, and owning cluster. When the cluster changes, the
/// decision-#122 reassignment guard blocks the move if any driver-bound equipment under the
/// reassignment guard blocks the move if any driver-bound equipment under the
/// area is bound to a driver in a different cluster than the target. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsArea.RowVersion"/>.
/// </summary>
@@ -231,7 +231,7 @@ public interface IUnsTreeService
/// <param name="newClusterId">The target cluster (may equal the current one).</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
/// <returns>Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
@@ -283,21 +283,21 @@ public interface IUnsTreeService
/// <summary>
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
/// (decision #125: <c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
/// decision-#122 driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
/// driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
/// collapse to <c>null</c>.
/// </summary>
/// <param name="input">The operator-editable equipment fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
/// a driver-bound row whose driver is in a different cluster than its line fails the decision-#122
/// a driver-bound row whose driver is in a different cluster than its line fails the cluster
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
@@ -310,7 +310,7 @@ public interface IUnsTreeService
/// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
/// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks
/// ids, and the OPC 40010 identification fields). The driver-cluster guard blocks
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
/// </summary>
@@ -318,7 +318,7 @@ public interface IUnsTreeService
/// <param name="input">The new operator-editable equipment fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
/// <returns>Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
@@ -361,7 +361,7 @@ public interface IUnsTreeService
/// <summary>
/// Loads the drivers eligible to back a tag on the given equipment: drivers in the equipment's
/// cluster (<c>Equipment.UnsLine → UnsArea.ClusterId</c>) whose namespace is Equipment-kind
/// (decision #110 — tree tags are equipment-bound). Ordered by <c>DriverInstanceId</c>. Returns
/// (tree tags are equipment-bound). Ordered by <c>DriverInstanceId</c>. Returns
/// an empty list when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
@@ -373,10 +373,10 @@ public interface IUnsTreeService
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
/// the tree only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (the tree
/// only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
/// <c>TagConfig</c> JSON, an unknown driver, a driver whose namespace is not Equipment-kind, a
/// driver in a different cluster than the equipment (decision #122), or a name already used on
/// driver in a different cluster than the equipment, or a name already used on
/// the equipment. Whitespace-only <c>PollGroupId</c> collapses to <c>null</c>.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
@@ -388,8 +388,8 @@ public interface IUnsTreeService
/// <summary>
/// Updates an equipment-bound tag's driver binding, name, data type, access level, write-retry
/// flag, poll group, and config. The owning <c>EquipmentId</c> and the <c>null</c>
/// <c>FolderPath</c> are preserved. Re-runs the JSON-validity, namespace-kind, and decision-#122
/// cluster guards against the tag's existing equipment, and enforces name uniqueness on that
/// <c>FolderPath</c> are preserved. Re-runs the JSON-validity, namespace-kind, and cluster
/// guards against the tag's existing equipment, and enforces name uniqueness on that
/// equipment excluding this tag. Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
@@ -432,7 +432,7 @@ public interface IUnsTreeService
Task<UnsMutationResult> CreateScriptAsync(string name, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound virtual tag (plan decision #2 — virtual tags are always scoped
/// Creates a new equipment-bound virtual tag (virtual tags are always scoped
/// to an equipment). Fails if the equipment does not exist, if no script is chosen, if neither a
/// change trigger nor a timer is set, if the timer is below the 50 ms minimum, on a duplicate
/// <c>VirtualTagId</c>, or on a name already used on the equipment.
@@ -20,6 +20,8 @@ public sealed class AbCipTagConfigModel
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The TagConfig JSON string to parse, or <c>null</c>/empty for a new tag.</param>
/// <returns>The populated model.</returns>
public static AbCipTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -34,6 +36,7 @@ public sealed class AbCipTagConfigModel
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
@@ -43,6 +46,7 @@ public sealed class AbCipTagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null;
}
@@ -20,6 +20,8 @@ public sealed class AbLegacyTagConfigModel
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The TagConfig JSON string to parse, or <c>null</c>/empty for a new tag.</param>
/// <returns>The populated model.</returns>
public static AbLegacyTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -34,6 +36,7 @@ public sealed class AbLegacyTagConfigModel
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
@@ -43,6 +46,7 @@ public sealed class AbLegacyTagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(Address))
@@ -20,6 +20,8 @@ public sealed class FocasTagConfigModel
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or null/empty for a fresh model.</param>
/// <returns>The populated model, defaulted where fields are absent from <paramref name="json"/>.</returns>
public static FocasTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -34,6 +36,7 @@ public sealed class FocasTagConfigModel
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
@@ -43,6 +46,7 @@ public sealed class FocasTagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -29,6 +29,8 @@ public sealed class ModbusTagConfigModel
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
/// <returns>The populated <see cref="ModbusTagConfigModel"/>.</returns>
public static ModbusTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -46,6 +48,7 @@ public sealed class ModbusTagConfigModel
/// <summary>Serialises this model back to a TagConfig JSON string, writing the six exposed fields
/// (enums as their name strings) over the preserved key bag.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "region", Region);
@@ -58,5 +61,6 @@ public sealed class ModbusTagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate() => null;
}
@@ -43,6 +43,8 @@ public sealed class NativeAlarmModel
/// <summary>Loads a model from a TagConfig JSON string. When no <c>alarm</c> object is present the
/// model is flagged non-alarm and the alarm fields keep their defaults. Retains every original key
/// at the root and inside <c>alarm</c> so a load→save preserves fields this editor doesn't expose.</summary>
/// <param name="json">The tag's raw <c>TagConfig</c> JSON, or null/empty for a fresh config.</param>
/// <returns>The populated model.</returns>
public static NativeAlarmModel FromJson(string? json)
{
var root = TagConfigJson.ParseOrNew(json);
@@ -63,6 +65,7 @@ public sealed class NativeAlarmModel
/// <summary>Serialises this model back to a TagConfig JSON string. When <see cref="IsAlarm"/> is set,
/// writes the alarm fields over the preserved <c>alarm</c> sub-object (creating it if absent); a
/// <c>null</c> <see cref="HistorizeToAveva"/> REMOVES the key so the absent ⇒ historize default holds.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
if (IsAlarm)
@@ -92,6 +95,7 @@ public sealed class NativeAlarmModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
public string? Validate() => null;
/// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the
@@ -111,6 +115,8 @@ public sealed class NativeAlarmModel
/// preserved: when an <c>alarm</c> object already exists this returns the input unchanged (never
/// overwrites an authored alarm). Returns the (possibly unchanged) TagConfig JSON string.
/// </summary>
/// <param name="json">The tag's raw <c>TagConfig</c> JSON, or null/empty for a fresh config.</param>
/// <returns>The (possibly unchanged) TagConfig JSON string with the default alarm seeded when applicable.</returns>
public static string SeedDefaultAlarm(string? json)
{
var root = TagConfigJson.ParseOrNew(json);
@@ -24,6 +24,7 @@ public sealed class OpcUaClientTagConfigModel
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The tag's TagConfig JSON (null/blank/malformed ⇒ defaults).</param>
/// <returns>The populated model.</returns>
public static OpcUaClientTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -38,6 +39,7 @@ public sealed class OpcUaClientTagConfigModel
/// <c>FullName</c> is written PascalCase (the composer/walker contract key); any history keys merged
/// by the TagModal (<c>isHistorized</c> / <c>historianTagname</c>) are carried through untouched as
/// preserved unknown keys.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "FullName", FullName.Trim());
@@ -45,6 +47,7 @@ public sealed class OpcUaClientTagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(FullName) ? "An upstream node reference (FullName) is required." : null;
}
@@ -20,6 +20,8 @@ public sealed class S7TagConfigModel
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The raw TagConfig JSON string, or null/empty for a fresh model.</param>
/// <returns>The populated model, defaulted where fields are absent from <paramref name="json"/>.</returns>
public static S7TagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -34,6 +36,7 @@ public sealed class S7TagConfigModel
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string) over the preserved key bag.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "address", Address.Trim());
@@ -43,6 +46,7 @@ public sealed class S7TagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -27,6 +27,8 @@ public static class TagArrayConfig
/// <summary>Reads the array-shape keys from a TagConfig JSON, defaulting any absent key
/// (null/blank/malformed input ⇒ <c>(false, null)</c>). A negative or non-numeric <c>arrayLength</c>
/// reads as <c>null</c>.</summary>
/// <param name="json">The TagConfig JSON to read from, or <c>null</c>.</param>
/// <returns>The parsed array-shape state.</returns>
public static ArrayState Read(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -39,6 +41,10 @@ public static class TagArrayConfig
/// (driver-specific or unknown) key. <c>isArray</c> is dropped when false (absent ⇒ false at the
/// materialiser); <c>arrayLength</c> is dropped when <paramref name="isArray"/> is false or
/// <paramref name="arrayLength"/> is null (never leaves an orphan length behind a cleared isArray).</summary>
/// <param name="json">The existing TagConfig JSON to merge into, or <c>null</c> for a new config.</param>
/// <param name="isArray">Whether the tag should be materialised as a 1-D array.</param>
/// <param name="arrayLength">The array length to store; ignored when <paramref name="isArray"/> is false.</param>
/// <returns>The merged TagConfig JSON string.</returns>
public static string Set(string? json, bool isArray, uint? arrayLength)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -52,6 +58,9 @@ public static class TagArrayConfig
/// <summary>Validates the array-shape intent: when <paramref name="isArray"/> is set, a positive
/// <paramref name="arrayLength"/> is required. Returns an error string when invalid, or <c>null</c>
/// when valid (a non-array is always valid regardless of length).</summary>
/// <param name="isArray">Whether the tag is configured as a 1-D array.</param>
/// <param name="arrayLength">The configured array length, or <c>null</c> when unset.</param>
/// <returns>An error message when the combination is invalid; otherwise <c>null</c>.</returns>
public static string? Validate(bool isArray, uint? arrayLength)
=> isArray && (arrayLength is null or 0)
? "Array length must be a positive number when 'This tag is an array' is checked."
@@ -20,6 +20,8 @@ public static class TagConfigEditorMap
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
/// <param name="driverType">The driver type name to look up an editor component for.</param>
/// <returns>The editor component <see cref="Type"/>, or null if no typed editor is registered.</returns>
public static Type? Resolve(string? driverType)
=> driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
}
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
public static class TagConfigJson
{
/// <summary>Parses <paramref name="json"/> into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input.</summary>
/// <param name="json">The JSON string to parse.</param>
/// <returns>The parsed <see cref="JsonObject"/>, or a fresh empty object on invalid input.</returns>
public static JsonObject ParseOrNew(string? json)
{
if (string.IsNullOrWhiteSpace(json)) { return new JsonObject(); }
@@ -19,25 +21,46 @@ public static class TagConfigJson
}
/// <summary>Serialises the object to compact JSON (JsonNode.ToJsonString() defaults to non-indented).</summary>
/// <param name="obj">The JSON object to serialise.</param>
/// <returns>The compact JSON string.</returns>
public static string Serialize(JsonObject obj) => obj.ToJsonString();
/// <summary>Reads a string value, or null if absent/null/non-string.</summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <returns>The string value, or null if absent, null, or non-string.</returns>
public static string? GetString(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric (incl. object/array nodes).</summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <param name="fallback">The value to return if the property is absent, null, or non-numeric.</param>
/// <returns>The int value, or <paramref name="fallback"/>.</returns>
public static int GetInt(JsonObject o, string name, int fallback = 0)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : fallback;
/// <summary>Reads a bool value, or <paramref name="fallback"/> if absent/null/non-boolean (incl. object/array/number/string nodes).</summary>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <param name="fallback">The value to return if the property is absent, null, or non-boolean.</param>
/// <returns>The bool value, or <paramref name="fallback"/>.</returns>
public static bool GetBool(JsonObject o, string name, bool fallback = false)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<bool>(out var b) ? b : fallback;
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The JSON object to read from.</param>
/// <param name="name">The property name to read.</param>
/// <param name="fallback">The value to return if the property is absent or unparseable.</param>
/// <returns>The parsed enum value, or <paramref name="fallback"/>.</returns>
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
/// <summary>Sets a string/number/enum-name value (enums via ToString()). A null value REMOVES the key, so it is omitted from the serialised JSON.</summary>
/// <param name="o">The JSON object to mutate.</param>
/// <param name="name">The property name to set.</param>
/// <param name="value">The value to set, or null to remove the property.</param>
public static void Set(JsonObject o, string name, object? value)
{
if (value is null) { o.Remove(name); return; }
@@ -52,6 +75,9 @@ public static class TagConfigJson
/// <c>alarm</c> object (and root history/array intent, scaling, etc.). Null/blank/malformed input starts
/// from a bare <c>{"FullName":...}</c> object.
/// </summary>
/// <param name="json">The existing TagConfig JSON string, or null/blank/malformed to start fresh.</param>
/// <param name="fullName">The newly-picked Galaxy <c>tag_name.AttributeName</c> reference.</param>
/// <returns>The updated TagConfig serialised back to a JSON string.</returns>
public static string SetFullName(string? json, string fullName)
{
// A blank reference is never a valid Galaxy bind — surface it loudly rather than persisting
@@ -25,6 +25,9 @@ public static class TagConfigValidator
/// Validates a tag's <paramref name="configJson"/> for the given <paramref name="driverType"/>.
/// Returns an error string to block save, or null when valid / no typed validator is registered.
/// </summary>
/// <param name="driverType">The driver type name to look up a typed validator for.</param>
/// <param name="configJson">The tag's config JSON to validate.</param>
/// <returns>An error string to block save, or null when the config is valid or unmapped.</returns>
public static string? Validate(string? driverType, string? configJson)
=> driverType is not null && Validators.TryGetValue(driverType, out var v) ? v(configJson) : null;
}
@@ -24,6 +24,8 @@ public static class TagHistorizeConfig
/// <summary>Reads the history-intent keys from a TagConfig JSON, defaulting any absent key
/// (null/blank/malformed input ⇒ <c>(false, "")</c>).</summary>
/// <param name="json">The tag's TagConfig JSON, or null/empty/malformed input.</param>
/// <returns>The parsed history-intent state, defaulted to <c>(false, "")</c> when keys are absent.</returns>
public static HistorizeState Read(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -36,6 +38,10 @@ public static class TagHistorizeConfig
/// (driver-specific or unknown) key. <c>isHistorized</c> is dropped when false (absent ⇒ false at the
/// composer); <c>historianTagname</c> is dropped when null/blank (absent ⇒ defaults to FullName) and
/// trimmed otherwise.</summary>
/// <param name="json">The tag's existing TagConfig JSON, or null/empty to start from a new object.</param>
/// <param name="isHistorized">Whether the server should expose OPC UA HistoryRead over this tag's node.</param>
/// <param name="historianTagname">Optional historian tagname override; null/blank omits the key.</param>
/// <returns>The merged TagConfig JSON with the two history-intent keys applied.</returns>
public static string Set(string? json, bool isHistorized, string? historianTagname)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -20,6 +20,8 @@ public sealed class TwinCATTagConfigModel
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
/// <param name="json">The TagConfig JSON string to parse, or null/empty for a fresh model.</param>
/// <returns>A populated <see cref="TwinCATTagConfigModel"/>.</returns>
public static TwinCATTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
@@ -34,6 +36,7 @@ public sealed class TwinCATTagConfigModel
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
/// <returns>The serialised TagConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
@@ -43,6 +46,7 @@ public sealed class TwinCATTagConfigModel
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
}
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <param name="DriverInstanceId">The bound driver; must resolve to an Equipment-kind namespace in the equipment's cluster.</param>
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
/// <param name="AccessLevel">Tag-level OPC UA access baseline.</param>
/// <param name="WriteIdempotent">Whether writes are safe to retry (decisions #4445).</param>
/// <param name="WriteIdempotent">Whether writes are safe to retry.</param>
/// <param name="PollGroupId">Optional poll-group batching key; whitespace/empty collapses to <c>null</c>.</param>
/// <param name="TagConfig">Schemaless per-driver-type JSON config; validated for JSON well-formedness.</param>
public sealed record TagInput(
@@ -96,6 +96,11 @@ public static class UnsTreeAssembly
/// (and enterprises whose clusters have no areas) are retained. Ordering is
/// deterministic and ordinal at every level.
/// </summary>
/// <param name="clusters">The flat set of cluster rows to nest under the enterprise root.</param>
/// <param name="areas">The flat set of area rows to nest under their owning clusters.</param>
/// <param name="lines">The flat set of line rows to nest under their owning areas.</param>
/// <param name="equipment">The flat set of equipment rows to nest under their owning lines.</param>
/// <returns>The assembled read-only list of top-level UNS tree nodes.</returns>
public static IReadOnlyList<UnsNode> Build(
IReadOnlyList<ClusterRow> clusters,
IReadOnlyList<AreaRow> areas,
@@ -293,7 +293,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Decision #122: a cluster move must not orphan driver-bound equipment from its driver's
// A cluster move must not orphan driver-bound equipment from its driver's
// cluster. Any equipment under this area that is bound to a driver in a different cluster
// than the target blocks the move.
if (newClusterId != entity.ClusterId)
@@ -416,7 +416,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Decision #122: a reparent to a different area must not orphan driver-bound equipment
// A reparent to a different area must not orphan driver-bound equipment
// from its driver's cluster. Resolve the new area's cluster and check every bound
// equipment item under this line against it.
if (newUnsAreaId != entity.UnsAreaId)
@@ -580,8 +580,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
continue;
}
// Reuse the #122 guard: it reports an unknown DriverInstanceId and a driver/line cluster
// mismatch, and is a no-op for driver-less rows.
// Reuse the driver/line cluster guard: it reports an unknown DriverInstanceId and a
// driver/line cluster mismatch, and is a no-op for driver-less rows.
var guard = await CheckDriverClusterGuardAsync(db, row, ct);
if (guard is not null)
{
@@ -838,7 +838,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return Array.Empty<(string, string, string, string)>();
}
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
// Drivers in the equipment's cluster whose namespace is Equipment-kind.
// GalaxyMxGateway is an ordinary Equipment-kind driver post-de-split, so it surfaces here
// alongside the PLC drivers; its DriverConfig feeds the Galaxy live-browse address picker.
var equipmentNamespaceIds = await db.Namespaces
@@ -1142,7 +1142,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
// EquipmentId is preserved — virtual tags are always equipment-bound (plan decision #2).
// EquipmentId is preserved — virtual tags are always equipment-bound.
entity.Name = input.Name;
entity.DataType = input.DataType;
entity.ScriptId = input.ScriptId;
@@ -1283,7 +1283,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
/// <summary>Returns <c>true</c> if <paramref name="json"/> parses as a well-formed JSON document.
/// Null/blank input is treated as invalid (not well-formed JSON) so every caller gets the friendly
/// "not valid JSON" result rather than an unhandled <see cref="ArgumentNullException"/> from
/// <c>JsonDocument.Parse(null)</c> (AdminUI-002).</summary>
/// <c>JsonDocument.Parse(null)</c>.</summary>
private static bool IsValidJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -1335,7 +1335,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
/// <summary>
/// Validates a tree tag's driver binding: the driver must exist, live in an Equipment-kind
/// namespace (decision #110), and be in the same cluster as the owning equipment (decision #122).
/// namespace, and be in the same cluster as the owning equipment.
/// Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
/// </summary>
private static async Task<UnsMutationResult?> CheckTagDriverGuardAsync(
@@ -1367,7 +1367,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <summary>
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
/// An equipment may only bind to a driver in the same cluster as its line.
/// Policy:
/// <list type="bullet">
/// <item>Driver-less (<c>DriverInstanceId</c> empty) → always allowed (returns <c>null</c>).</item>
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for an equipment-bound VirtualTag create
/// or update via the UNS tree. Virtual tags are always scoped to an equipment (plan decision #2), so
/// or update via the UNS tree. Virtual tags are always scoped to an equipment, so
/// the owning <c>EquipmentId</c> is supplied separately and never changes on update. A virtual tag
/// must have at least one evaluation trigger: <see cref="ChangeTriggered"/> or a non-null
/// <see cref="TimerIntervalMs"/> (which, when set, must be at least 50 ms).
@@ -175,7 +175,7 @@ public sealed class AdminOperationsActor : ReceiveActor
var draft = await DraftSnapshotFactory.FromConfigDbAsync(db);
var errors = DraftValidator.Validate(draft).ToList();
// Cluster-topology guard (decision #91 / task #148 part 2). The SQL
// Cluster-topology guard. The SQL
// CK_ServerCluster_RedundancyMode_NodeCount CHECK enforces the (NodeCount, RedundancyMode)
// pair on the row itself, but it cannot see the per-node ClusterNode.Enabled flag — an
// operator can disable a node (effective enabled-count = 1) while leaving RedundancyMode at
@@ -25,6 +25,7 @@ public static class ConfigComposer
/// <summary>Reads the current configuration and returns a deterministic snapshot blob with revision hash.</summary>
/// <param name="db">The configuration database context.</param>
/// <param name="ct">The cancellation token for the operation.</param>
/// <returns>A deterministic <see cref="ConfigArtifact"/> containing the serialised snapshot bytes and its revision hash.</returns>
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
OtOpcUaConfigDbContext db, CancellationToken ct = default)
{
@@ -53,6 +54,7 @@ public static class ConfigComposer
/// <summary>Returns the SHA-256 hex digest of the supplied artifact bytes (lowercase, no prefix).</summary>
/// <param name="blob">The bytes to hash.</param>
/// <returns>The lowercase hex-encoded SHA-256 digest.</returns>
public static string HashOf(ReadOnlySpan<byte> blob) =>
Convert.ToHexStringLower(SHA256.HashData(blob));
}
@@ -39,6 +39,7 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers, IAuditWriter
/// <summary>Creates a Props factory for the AuditWriterActor.</summary>
/// <param name="dbFactory">The database context factory for creating ConfigDb connections.</param>
/// <returns>The <see cref="Akka.Actor.Props"/> used to create an <see cref="AuditWriterActor"/>.</returns>
public static Props Props(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) =>
Akka.Actor.Props.Create(() => new AuditWriterActor(dbFactory));
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
/// <see cref="DispatchDeployment"/> over DistributedPubSub on the <c>deployments</c> topic, gathers
/// per-node <see cref="ApplyAck"/> replies, and seals the deployment when every expected node
/// has acked Applied. Per-node ACKs are persisted in <c>NodeDeploymentState</c> so a failover of
/// this singleton (Task 31) can recover in-flight state from the DB.
/// this singleton can recover in-flight state from the DB.
///
/// Discovery of the "expected ACK set" comes from <c>Akka.Cluster.State.Members</c> filtered by
/// the <c>driver</c> role — the DB does not own per-node role assignment.
@@ -46,6 +46,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
/// <summary>Creates actor props for the ConfigPublishCoordinator with the specified configuration.</summary>
/// <param name="dbFactory">The database context factory for accessing configuration data.</param>
/// <param name="applyDeadline">The timeout for waiting for apply acknowledgments from cluster nodes.</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
TimeSpan? applyDeadline = null) =>
@@ -68,14 +69,12 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
Receive<SubscribeAck>(_ => { /* DPS subscribe confirmation */ });
}
/// <summary>
/// On startup recover any deployment that was mid-flight when a prior singleton instance
/// died. We re-derive <c>_expectedAcks</c> from <c>NodeDeploymentState</c>, replay the ACKs
/// that already landed in the DB, and resume the deadline timer.
/// </summary>
/// <inheritdoc />
protected override void PreStart()
{
// On startup recover any deployment that was mid-flight when a prior singleton instance
// died. We re-derive _expectedAcks from NodeDeploymentState, replay the ACKs
// that already landed in the DB, and resume the deadline timer.
// Subscribe to per-node ApplyAck broadcasts so DriverHostActors on remote members can
// route their ACKs to whichever node currently hosts this singleton.
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DeploymentAcksTopic, Self));
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
/// <summary>
/// Admin-role cluster singleton that maintains an in-memory <see cref="FleetStatusChanged"/>
/// snapshot from cluster membership + reachability events plus per-node heartbeats, and pushes
/// diffs on the <c>fleet-status</c> DistributedPubSub topic. The SignalR hub layer (Task 49)
/// diffs on the <c>fleet-status</c> DistributedPubSub topic. The SignalR hub layer
/// subscribes to that topic and forwards to browser clients.
///
/// Heartbeat staleness: a node we haven't heard from in <see cref="HeartbeatTimeout"/> flips
@@ -36,6 +36,7 @@ public static class DriverFactoryBootstrap
/// <see cref="IDriverFactory"/> from DI when spawning <c>DriverHostActor</c>.
/// </summary>
/// <param name="services">The service collection to register driver factories with.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaDriverFactories(this IServiceCollection services)
{
services.AddSingleton<DriverFactoryRegistry>(sp =>
@@ -73,6 +74,7 @@ public static class DriverFactoryBootstrap
/// </para>
/// </summary>
/// <param name="services">The service collection to register driver probes with.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
@@ -43,11 +43,7 @@ public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposabl
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
}
/// <summary>Evaluates a virtual tag expression against a set of dependencies.</summary>
/// <param name="virtualTagId">The virtual tag identifier, used for logging.</param>
/// <param name="expression">The C# expression to evaluate.</param>
/// <param name="dependencies">Dictionary of tag names to values available in the expression context.</param>
/// <returns>The evaluation result, either successful with a value or failed with an error message.</returns>
/// <inheritdoc />
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
@@ -14,6 +14,8 @@ public static class HealthEndpoints
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
/// ready+active; admin-leader on active only.
/// </summary>
/// <param name="services">The service collection to register the health checks on.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services)
{
services.AddHealthChecks()
@@ -40,6 +42,7 @@ public static class HealthEndpoints
/// <summary>Maps the OtOpcUa health check endpoints to the route builder.</summary>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The same endpoint route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
{
app.MapZbHealth();
@@ -21,6 +21,7 @@ public static class ObservabilityExtensions
/// <c>Otlp</c>; <c>OtOpcUa:Telemetry:OtlpEndpoint</c> sets the OTLP endpoint. With no
/// config the exporter stays Prometheus (the default).
/// </param>
/// <returns>The service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services, IConfiguration configuration)
{
return services.AddZbTelemetry(o =>
@@ -44,6 +45,7 @@ public static class ObservabilityExtensions
/// <c>MapZbHealth</c>, which also marks its endpoints anonymous.
/// </summary>
/// <param name="app">The endpoint route builder.</param>
/// <returns>The endpoint route builder, for chaining.</returns>
public static IEndpointRouteBuilder MapOtOpcUaMetrics(this IEndpointRouteBuilder app)
{
app.MapZbMetrics().AllowAnonymous();
@@ -26,10 +26,7 @@ public sealed class LdapOpcUaUserAuthenticator(
ILogger<LdapOpcUaUserAuthenticator> logger)
: IOpcUaUserAuthenticator
{
/// <summary>Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper.</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The password to authenticate.</param>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public async Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
{
try
@@ -98,6 +98,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// Starts the OPC UA server asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StartAsync(CancellationToken cancellationToken)
{
_server = new OtOpcUaSdkServer();
@@ -236,6 +237,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// Stops the OPC UA server asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken cancellationToken)
{
// Revert to Null adapters so any in-flight writes from a poison-pilled actor don't hit a
@@ -254,6 +256,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// <summary>
/// Disposes the hosted service and its resources asynchronously.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_appHost is not null) await _appHost.DisposeAsync();
+3 -3
View File
@@ -88,7 +88,7 @@ Directory.SetCurrentDirectory(AppContext.BaseDirectory);
builder.AddZbSerilog(o => o.ServiceName = "otopcua");
// Windows-service registration is handled at install time by scripts/install/Install-Services.ps1
// (Task 62) rather than in-process, so the binary stays cross-platform-compilable.
// rather than in-process, so the binary stays cross-platform-compilable.
// Shared services — always registered regardless of role. ConfigDb is required for everything.
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
@@ -222,8 +222,8 @@ if (hasDriver)
// Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never
// races Akka startup. ScriptRootLogger wraps the composed pipeline (rolling scripts-*.log +
// error mirror to the main log + script-logs DPS topic) for unambiguous DI resolution; Task 3
// injects it into the Roslyn evaluators above.
// error mirror to the main log + script-logs DPS topic) for unambiguous DI resolution; it
// injects into the Roslyn evaluators above.
var scriptLogFilePath = builder.Configuration["Scripting:LogFilePath"] ?? "logs/scripts-.log";
var scriptLogTopicMinLevel = Enum.TryParse<LogEventLevel>(
builder.Configuration["Scripting:LogTopicMinLevel"], ignoreCase: true, out var parsedLevel)
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// tear-down + rebuild.</item>
/// </list>
///
/// This is the side-effecting layer Task 47 deferred to F14. It stays pure-of-SDK so
/// This is the side-effecting layer deferred to F14. It stays pure-of-SDK so
/// production binds a real SDK sink, dev/Mac binds <see cref="NullOpcUaAddressSpaceSink"/>,
/// and tests can capture every call.
/// </summary>
@@ -101,7 +101,7 @@ public sealed class AddressSpaceApplier
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count +
// OpcUaServer-001: a UNS Area/Line rename is an in-place change to an existing folder node.
// A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
@@ -122,7 +122,7 @@ public sealed class AddressSpaceApplier
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild.
// F10b + FB-7 (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
// IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no
// alarm condition node) can be updated IN PLACE on the existing node via
// ISurgicalAddressSpaceSink.UpdateTagAttributes (see TagDeltaIsSurgicalEligible), avoiding the full
@@ -140,7 +140,7 @@ public sealed class AddressSpaceApplier
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d));
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList();
// OpcUaServer-001 — UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
// covered for free and need no separate surgical pass.
@@ -383,7 +383,7 @@ public sealed class AddressSpaceApplier
}
/// <summary>
/// #85 — build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// Build the UNS Area/Line/Equipment folder hierarchy in the address space from a
/// composition snapshot. Called by <c>OpcUaPublishActor</c> after a rebuild so OPC UA
/// clients browsing the server see proper folder structure instead of flat tag ids.
/// Idempotent: each <c>EnsureFolder</c> call returns the existing folder if already
@@ -420,19 +420,13 @@ public sealed class AddressSpaceApplier
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
/// Idempotent.
/// <para>
/// <b>Task 0 architecture decisions (recorded per the equipment-namespace-structure
/// plan).</b> Decision #1 = <b>A</b> — a sink-based pass, NOT a reuse of
/// <c>EquipmentNodeWalker</c>: no sink-backed <c>IAddressSpaceBuilder</c> adapter exists
/// (<c>GenericDriverNodeManager.CapturingBuilder</c> decorates another builder, not the
/// sink), and the walker re-creates the whole Area/Line/Equipment tree with browse-path
/// NodeIds — incompatible with this path's logical-Id NodeIds (decision #3) and the
/// already-materialised equipment folders (decision #4). Decision #4 = this pass adds
/// ONLY variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns
/// the equipment folders and this pass never re-creates them. The sink's
/// <c>EnsureVariable</c> takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// </para>
/// Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
/// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
/// </summary>
/// <param name="composition">The composition result containing the equipment tags to materialise.</param>
public void MaterialiseEquipmentTags(AddressSpaceComposition composition)
@@ -442,7 +436,7 @@ public sealed class AddressSpaceApplier
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
// equipment folder, which MaterialiseHierarchy already created (decision #4: never re-create
// equipment folder, which MaterialiseHierarchy already created (never re-create
// the equipment folder here).
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in composition.EquipmentTags)
@@ -641,7 +635,7 @@ public sealed class AddressSpaceApplier
Historize = d.Current.Historize,
}).Equals(d.Current);
// F10b + FB-7: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
@@ -650,7 +644,7 @@ public sealed class AddressSpaceApplier
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
// override-unequal default also covers any future field.
// REACH (live-verified FB-7): the shape path only fires for drivers whose TagConfig carries a stable
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
// AddressSpaceComposer.ExtractTagFullName falls back to the RAW TagConfig blob as FullName, so a DataType/
@@ -171,11 +171,11 @@ public sealed record EquipmentVirtualTagPlan(
IReadOnlyList<string> DependencyRefs,
bool Historize = false)
{
/// <summary>Structural equality: the auto-generated record equality would compare
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every
/// VirtualTag as "changed" on every parse (fresh list instances). Compare it element-wise
/// so a no-op redeploy diffs empty. <see cref="Historize"/> is included so a Historize-only
/// toggle is detected as a change.</summary>
/// <inheritdoc />
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
// interface-typed list) BY REFERENCE, flagging every VirtualTag as "changed" on every parse
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty. Historize
// is included so a Historize-only toggle is detected as a change.
public bool Equals(EquipmentVirtualTagPlan? other) =>
other is not null &&
VirtualTagId == other.VirtualTagId &&
@@ -187,6 +187,7 @@ public sealed record EquipmentVirtualTagPlan(
Historize == other.Historize &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
/// <inheritdoc />
public override int GetHashCode()
{
var hash = new HashCode();
@@ -238,17 +239,15 @@ public sealed record EquipmentScriptedAlarmPlan(
bool Retain,
bool Enabled)
{
/// <summary>Structural equality: the auto-generated record equality would compare
/// <see cref="DependencyRefs"/> (an interface-typed list) BY REFERENCE, flagging every alarm as
/// "changed" on every parse (fresh list instances). Compare it element-wise so a no-op redeploy
/// diffs empty (mirrors <see cref="EquipmentVirtualTagPlan"/>).</summary>
/// <remarks>
/// <b>DependencyRefs equality is order-sensitive</b> (SequenceEqual).
/// <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> is the canonical, deterministic
/// producer of that order (predicate <c>ctx.GetTag</c> reads first, then first-seen message
/// template tokens). Downstream byte-parity between the live composer and the artifact-decode
/// mirror depends on both sides calling <c>ExtractAlarmDependencyRefs</c> with identical inputs.
/// </remarks>
/// <inheritdoc />
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
// interface-typed list) BY REFERENCE, flagging every alarm as "changed" on every parse (fresh
// list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
// EquipmentVirtualTagPlan). DependencyRefs equality is order-sensitive (SequenceEqual).
// EquipmentScriptPaths.ExtractAlarmDependencyRefs is the canonical, deterministic producer of
// that order (predicate ctx.GetTag reads first, then first-seen message template tokens).
// Downstream byte-parity between the live composer and the artifact-decode mirror depends on
// both sides calling ExtractAlarmDependencyRefs with identical inputs.
public bool Equals(EquipmentScriptedAlarmPlan? other) =>
other is not null &&
ScriptedAlarmId == other.ScriptedAlarmId &&
@@ -280,9 +279,9 @@ public sealed record EquipmentScriptedAlarmPlan(
/// <summary>
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
/// startup (Task 53) consumes the result and hands it to the node-manager factory.
/// startup consumes the result and hands it to the node-manager factory.
///
/// #85 — the composer now carries UNS topology (<see cref="UnsAreaProjection"/> +
/// The composer also carries UNS topology (<see cref="UnsAreaProjection"/> +
/// <see cref="UnsLineProjection"/>) so <c>AddressSpaceApplier</c> can build the
/// <c>Area/Line/Equipment</c> folder hierarchy in the SDK's address space. The legacy
/// <c>EquipmentNodeWalker</c> integration that did this server-side is fully replaced by the
@@ -597,6 +596,8 @@ public static class AddressSpaceComposer
/// <summary>Parses the optional <c>alarm</c> object from a tag's <c>TagConfig</c> JSON. Returns null
/// when absent, non-object, or non-JSON (the tag is then a plain variable). Never throws. The
/// artifact-decode side (<c>DeploymentArtifact.ExtractTagAlarm</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed native-alarm intent, or <see langword="null"/> when the tag has no alarm object.</returns>
internal static EquipmentTagAlarmInfo? ExtractTagAlarm(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
@@ -627,6 +628,8 @@ public static class AddressSpaceComposer
/// resolved later). The raw string value is used — not trimmed — matching <c>ExtractTagFullName</c> /
/// <c>ExtractTagAlarm</c>. Never throws. The artifact-decode side
/// (<c>DeploymentArtifact.ExtractTagHistorize</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed historize flag and optional historian tagname override.</returns>
internal static (bool IsHistorized, string? HistorianTagname) ExtractTagHistorize(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
@@ -657,6 +660,8 @@ public static class AddressSpaceComposer
/// <see cref="ExtractTagHistorize"/> exactly in structure + null/blank/non-object/malformed-JSON
/// tolerance. Never throws. The artifact-decode side
/// (<c>DeploymentArtifact.ExtractTagArray</c>) MUST parse identically (byte-parity).</summary>
/// <param name="tagConfig">The tag's raw <c>TagConfig</c> JSON (nullable/blank tolerated).</param>
/// <returns>The parsed array flag and optional array length.</returns>
internal static (bool IsArray, uint? ArrayLength) ExtractTagArray(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return (false, null);
@@ -53,7 +53,7 @@ public sealed record AddressSpacePlan(
public IReadOnlyList<EquipmentVirtualTagDelta> ChangedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagDelta>();
/// <summary>
/// OpcUaServer-001 — UNS Area / Line folder renames: a folder whose stable id is unchanged but
/// UNS Area / Line folder renames: a folder whose stable id is unchanged but
/// whose <c>DisplayName</c> differs between the previous + next composition. A deploy whose ONLY
/// change is an Area or Line rename produces NO Equipment / Driver / Alarm / Tag / VirtualTag
/// delta, so without this set the plan would be <see cref="IsEmpty"/> and
@@ -84,7 +84,7 @@ public sealed record AddressSpacePlan(
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
/// <summary>OpcUaServer-001 — one renamed UNS Area / Line folder: the stable folder
/// <summary>One renamed UNS Area / Line folder: the stable folder
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
/// to apply in place.</summary>
@@ -103,6 +103,7 @@ public static class AddressSpacePlanner
/// </summary>
/// <param name="previous">The previous composition result.</param>
/// <param name="next">The new composition result.</param>
/// <returns>The diff plan between the two compositions.</returns>
public static AddressSpacePlan Compute(AddressSpaceComposition previous, AddressSpaceComposition next)
{
ArgumentNullException.ThrowIfNull(previous);
@@ -138,7 +139,7 @@ public static class AddressSpacePlanner
t => t.VirtualTagId,
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
// OpcUaServer-001 — UNS Area / Line renames: a folder whose stable id is unchanged but whose
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
// IS the area/line id (the exact scheme MaterialiseHierarchy uses), so the rename carries it
@@ -163,7 +164,7 @@ public static class AddressSpacePlanner
}
/// <summary>
/// OpcUaServer-001 — emit a <see cref="AddressSpacePlan.FolderRename"/> for every folder present in
/// Emit a <see cref="AddressSpacePlan.FolderRename"/> for every folder present in
/// BOTH snapshots (matched by stable <paramref name="identity"/>) whose <paramref name="displayName"/>
/// differs (ordinal). Added/removed folders are NOT renames — they're handled by the equipment /
/// hierarchy rebuild path — so this pass only flags an in-place display-name change on a surviving
@@ -122,6 +122,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// <summary>Starts the OPC UA application and server.</summary>
/// <param name="server">The standard server instance to start.</param>
/// <param name="cancellationToken">A cancellation token for the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
{
_server = server;
@@ -377,6 +378,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// listening endpoints — the misconfiguration is logged and very visible.
/// </summary>
/// <param name="profiles">The security profiles to build policies for.</param>
/// <returns>The SDK security policies for the requested profiles.</returns>
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
{
var seen = new HashSet<OpcUaSecurityProfile>();
@@ -424,6 +426,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// project supplies the LDAP adapter, and <see cref="NullOpcUaUserAuthenticator"/> denies all
/// UserName logins when none is registered.
/// </summary>
/// <returns>The Anonymous and UserName token policies.</returns>
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
{
yield return new UserTokenPolicy(UserTokenType.Anonymous)
@@ -439,6 +442,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
}
/// <summary>Disposes the application host and cleans up resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
@@ -58,7 +58,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
/// <summary>Phase C (Task 4): event-notifier folder NodeId-identifier → the event-history source
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
@@ -168,7 +168,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
/// independent of any driver's lifecycle.
/// <para>
/// Set by the Host at <c>StartAsync</c> (Task 5). The <see cref="NullHistorianDataSource"/>
/// Set by the Host at <c>StartAsync</c>. The <see cref="NullHistorianDataSource"/>
/// default (assigning <c>null</c> restores it) means "no historian wired" → every read
/// returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
/// faulting. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make
@@ -261,7 +261,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
lock (Lock)
{
@@ -297,7 +297,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
@@ -573,6 +573,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
/// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
/// <param name="isNative">True when the condition is a NATIVE (driver-fed, e.g. Galaxy) alarm rather than a scripted one.</param>
/// <remarks>
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
@@ -586,7 +587,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
lock (Lock)
{
@@ -693,7 +694,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
// "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
// AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
// engine enable/disable surface (Phase 3 decision #2) — they short-circuit to BadNotSupported.
// engine enable/disable surface — they short-circuit to BadNotSupported.
alarm.OnEnableDisable = (context, condition, enabling) =>
{
if (IsNativeAlarmNode(alarmNodeId))
@@ -720,6 +721,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
/// <param name="alarmNodeId">The alarm condition node id.</param>
/// <returns>True when the condition is native (driver-fed); false when it is scripted or not found.</returns>
internal bool IsNativeAlarmNode(string alarmNodeId)
{
// _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition /
@@ -811,7 +813,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
/// <see cref="EnsureVariable"/> (Task 11). The OPC UA SDK invokes it when a client writes the
/// <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
/// node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
/// SAME way <see cref="HandleAlarmCommand"/> does, gates on the
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role + the gateway being wired
@@ -1230,7 +1232,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
/// <remarks>
/// Phase C (Task 4): when a real historian is wired at promotion time (the source is NOT the
/// Phase C: when a real historian is wired at promotion time (the source is NOT the
/// <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
/// <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
/// its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> override
@@ -1272,7 +1274,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
/// #85 — used by <see cref="AddressSpaceApplier"/> to materialise the UNS Area/Line/Equipment
/// Used by <see cref="AddressSpaceApplier"/> to materialise the UNS Area/Line/Equipment
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
/// folder so adding child variables under it still works.
/// </summary>
@@ -1283,7 +1285,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
if (_folders.ContainsKey(folderNodeId)) return;
@@ -1310,7 +1312,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>
/// Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
/// subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
/// UNS Area / Line rename (OpcUaServer-001). <see cref="EnsureFolder"/> early-returns for an
/// UNS Area / Line rename. <see cref="EnsureFolder"/> early-returns for an
/// already-present folder id and never touches an existing folder's display name, so a
/// rename-only deploy would otherwise be invisible until a full <see cref="RebuildAddressSpace"/>
/// cleared <c>_folders</c>. The NodeId + BrowseName are unchanged (a rename only touches the
@@ -1351,7 +1353,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
/// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
/// to its <c>OnWriteValue</c> (Task 11) so a client write gates on the <c>WriteOperate</c> role + routes
/// to its <c>OnWriteValue</c> so a client write gates on the <c>WriteOperate</c> role + routes
/// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
/// <param name="historianTagname">Phase C: null ⇒ the node is NOT historized (Historizing=false, no
/// HistoryRead bit, not registered). Non-null ⇒ the node is created <c>Historizing</c> with the
@@ -1368,7 +1370,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated(); // OpcUaServer-004: fail legibly if called before the server started.
EnsureAddressSpaceCreated();
// If already present, leave it alone (idempotent re-applies).
if (_variables.ContainsKey(variableNodeId)) return;
@@ -1401,7 +1403,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
StatusCode = StatusCodes.BadWaitingForInitialData,
Timestamp = DateTime.MinValue,
};
// Task 11: a writable equipment tag owns an inbound-write handler. The SDK invokes
// A writable equipment tag owns an inbound-write handler. The SDK invokes
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
// driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
@@ -1437,7 +1439,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node survive.
/// Returns false when the node id is unknown (caller falls back to a full rebuild).
/// <para>
/// FB-7: when the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
/// When the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
/// happen. (1) The node's value is reset to <c>BadWaitingForInitialData</c> (Value=null) so no value
/// still typed to the OLD DataType is exposed between this update and the driver's next publish — the
/// same fresh-node state <see cref="EnsureVariable"/> creates, which closes the "brief value-type
@@ -1476,7 +1478,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (historized) _historizedTagnames[variableNodeId] = historianTagname!;
else _historizedTagnames.TryRemove(variableNodeId, out _);
// FB-7: swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
// Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
// from the live node — a Writable/Historizing-only change leaves the shape untouched (no value
// reset, no model-change event), preserving the original surgical behaviour byte-for-byte.
var newDataType = ResolveBuiltInDataType(dataType);
@@ -1727,7 +1729,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
// equipment folders to event notifiers.
_notifierFolders.Clear();
// Phase C (Task 4): drop the event-history source registrations alongside the notifier folders
// Phase C: drop the event-history source registrations alongside the notifier folders
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
_eventNotifierSources.Clear();
}
@@ -1740,7 +1742,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
}
/// <summary>OpcUaServer-004: guard the address-space mutators against a too-early call. <c>_root</c>
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
/// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>,
/// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
@@ -1766,7 +1768,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// that carry the HistoryRead access bit), validates the timestamp args, handles
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
// protected virtuals below. We override all four arms: the three variable-history virtuals
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents, Task 4). Each override
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents). Each override
// receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
// handle.Index is the original index into the service-level results/errors lists, seeded by the
// base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
@@ -1778,18 +1780,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// backend throw) never throws out of the batch.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Serve a HistoryRead-Raw request over the pre-filtered historized variable handles, dispatching
/// each to <see cref="IHistorianDataSource.ReadRawAsync"/>. Modified-history reads
/// (<c>IsReadModified</c>) are unsupported — we don't serve a modified-value history surface.
/// <para>
/// Raw is the only arm that pages server-side: <c>ReadRawModifiedDetails</c> carries a client
/// count cap (<c>NumValuesPerNode</c>), so a page that returns exactly that many samples MAY
/// have more behind it ⇒ a time-based continuation point is emitted (see
/// <see cref="ServeRawPaged"/>). An inbound continuation point on a node resumes its stored
/// read. <c>NumValuesPerNode == 0</c> ("all values") never pages.
/// </para>
/// </summary>
/// <inheritdoc />
protected override void HistoryReadRawModified(
ServerSystemContext context,
ReadRawModifiedDetails details,
@@ -1816,15 +1807,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Serve a HistoryRead-Processed request, mapping each node's per-node aggregate NodeId (from the
/// parallel <c>AggregateType</c> collection — the base guarantees it is the same length as
/// <c>nodesToRead</c>) to a <see cref="HistoryAggregateType"/> and dispatching to
/// <see cref="IHistorianDataSource.ReadProcessedAsync"/>. An unknown aggregate yields
/// <c>BadAggregateNotSupported</c> for that node. Single-shot (no continuation point):
/// <c>ReadProcessedDetails</c> carries no client count cap — the bucket count is deterministic
/// (window / interval) — so there is no "full page" signal to page on.
/// </summary>
/// <inheritdoc />
protected override void HistoryReadProcessed(
ServerSystemContext context,
ReadProcessedDetails details,
@@ -1864,12 +1847,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Serve a HistoryRead-AtTime request, dispatching the requested timestamps to
/// <see cref="IHistorianDataSource.ReadAtTimeAsync"/>. Single-shot (no continuation point):
/// AtTime carries no client count cap — the request IS the timestamp list and the result is
/// exactly one sample per requested timestamp — so there is no "full page" signal to page on.
/// </summary>
/// <inheritdoc />
protected override void HistoryReadAtTime(
ServerSystemContext context,
ReadAtTimeDetails details,
@@ -1891,18 +1869,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Serve a HistoryRead-Events request over the equipment-folder event-notifier nodes (the folders
/// that own alarm conditions). Each handle's NodeId identifier is resolved against
/// <see cref="_eventNotifierSources"/>: a miss ⇒ <c>BadHistoryOperationUnsupported</c> (a node we own
/// that isn't a registered event-history source — e.g. a plain folder, or one promoted while no
/// historian was wired); a hit block-bridges to <see cref="IHistorianDataSource.ReadEventsAsync"/>
/// for the folder's source name and projects each <see cref="HistoricalEvent"/> into a
/// <see cref="HistoryEventFieldList"/> per the request's event filter. Like the Raw/Processed/AtTime
/// arms this is NOT invoked under the node-manager <c>Lock</c>, so the block-bridge is safe; each
/// handle is served under try/catch so a backend throw becomes a Bad status for THAT node only and
/// never throws out of the batch.
/// </summary>
/// <inheritdoc />
protected override void HistoryReadEvents(
ServerSystemContext context,
ReadEventDetails details,
@@ -1936,7 +1903,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
sourceName,
details.StartTime,
details.EndTime,
// OpcUaServer-002: NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA
// Part 4/11, so translate it to UNBOUNDED (int.MaxValue) here. Passing the int<=0
// backend-default-cap sentinel instead would silently truncate a "give me everything"
// events read at the backend default. A positive cap passes through (clamped).
@@ -1974,7 +1941,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <returns>The clamped non-negative int.</returns>
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
/// <summary>OpcUaServer-002: map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> <c>maxEvents</c> argument, honouring the OPC UA
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
@@ -2315,14 +2282,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>
/// Drop the resume state for any continuation points the client asked to release
/// (<c>releaseContinuationPoints == true</c>) and return WITHOUT reading data, per OPC UA Part 4.
/// The base dispatcher routes a release-only HistoryRead here (it never reaches the per-details
/// arms), so this is the single place that must free Raw's stored cursors. Each handle's released
/// point is <c>nodesToRead[handle.Index].ContinuationPoint</c>; releasing an unknown / null point
/// is a harmless no-op. Errors are left Good (the base pre-seeds them) — a release does not fail.
/// </summary>
/// <inheritdoc />
protected override void HistoryReleaseContinuationPoints(
ServerSystemContext context,
IList<HistoryReadValueId> nodesToRead,
@@ -20,76 +20,37 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
_nodeManager = nodeManager;
}
/// <summary>Writes a value to the OPC UA address space.</summary>
/// <param name="nodeId">The OPC UA node identifier.</param>
/// <param name="value">The value being written.</param>
/// <param name="quality">The OPC UA quality status.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
/// <summary>Writes the full Part 9 alarm-condition state to the OPC UA address space.</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
/// <summary>Materialises a real Part 9 alarm-condition node in the address space.</summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node identifier the condition parents under.</param>
/// <param name="displayName">The human-readable condition name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
/// <param name="isNative">True for a driver-fed (native) equipment-tag alarm; false (default) for a scripted alarm.</param>
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
/// <summary>Ensures a folder node exists in the address space.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
/// <summary>Ensures a variable node exists in the address space.</summary>
/// <param name="variableNodeId">The variable node identifier.</param>
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="dataType">The OPC UA data type.</param>
/// <param name="writable">When true the node is created read/write; otherwise read-only.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ create Historizing with the
/// HistoryRead access bit and register the historian tagname.</param>
/// <param name="isArray">When true the node is created as a 1-D array; when false (default) scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
/// <summary>F10b: surgically update an existing variable node's Writable + Historizing + presentation
/// shape (DataType / array-ness) in place (no rebuild). Returns false when the node does not exist
/// (caller falls back to a full rebuild).</summary>
/// <param name="variableNodeId">The variable node identifier.</param>
/// <param name="writable">When true the node becomes read/write with the inbound-write handler; otherwise read-only.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.</param>
/// <param name="dataType">The OPC UA built-in data type name to apply in place.</param>
/// <param name="isArray">When true the node becomes a 1-D array; when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true.</param>
/// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
/// <summary>OpcUaServer-001: surgically update an existing folder node's display name in place (no
/// rebuild) for a UNS Area / Line rename. Returns false when the folder does not exist (caller falls
/// back to a full rebuild).</summary>
/// <param name="folderNodeId">The folder node identifier whose display name to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder is missing.</returns>
/// <inheritdoc />
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
/// <summary>Rebuilds the entire OPC UA address space.</summary>
/// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <summary>Announces a runtime NodeAdded model-change (discovered-node injection) to subscribed clients.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId);
}
@@ -32,8 +32,7 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
_logger = logger;
}
/// <summary>Publishes the service level to the OPC UA Server object.</summary>
/// <param name="serviceLevel">The service level value to publish.</param>
/// <inheritdoc />
public void Publish(byte serviceLevel)
{
var node = _serverInternal.ServerObject?.ServiceLevel;
@@ -55,11 +55,7 @@ public sealed class NullOpcUaUserAuthenticator : IOpcUaUserAuthenticator
public static readonly NullOpcUaUserAuthenticator Instance = new();
private NullOpcUaUserAuthenticator() { }
/// <summary>Authenticates a username (always denies in this null implementation).</summary>
/// <param name="username">The username to authenticate.</param>
/// <param name="password">The cleartext password to authenticate.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns a denial result indicating no authenticator is configured.</returns>
/// <inheritdoc />
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct) =>
Task.FromResult(OpcUaUserAuthResult.Deny("No UserName authenticator is configured on this server."));
}
@@ -25,7 +25,7 @@ public static class OpcUaDataPlaneRoles
/// <summary>The role that grants authority to write a writable equipment-tag variable node
/// (FreeAccess / Operate attributes). A session must carry this role for the inbound
/// <c>OnWriteValue</c> handler (Task 11) to route the value to the backing driver; absent it the
/// <c>OnWriteValue</c> handler to route the value to the backing driver; absent it the
/// write is denied with <c>BadUserAccessDenied</c> before any driver call.</summary>
public const string WriteOperate = "WriteOperate";
}
@@ -55,13 +55,17 @@ public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
/// <summary>A variable handle whose alarm marking is a no-op (discovered alarms are out of scope).</summary>
private sealed class NullHandle(string fullRef) : IVariableHandle
{
/// <inheritdoc />
public string FullReference => fullRef;
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
/// <summary>A null sink that ignores alarm condition transitions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <inheritdoc />
public void OnTransition(AlarmEventArgs args) { }
}
}
@@ -53,6 +53,7 @@ public static class DeploymentArtifact
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <returns>The parsed driver-instance specs, or an empty list for empty/malformed blobs.</returns>
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
{
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
@@ -91,7 +92,7 @@ public static class DeploymentArtifact
/// other parsers).
/// </summary>
/// <param name="blob">The deployment artifact blob to inspect.</param>
/// <param name="nodeId">The node's identity (e.g. "central-1:4053") to match against <c>Nodes</c>.</param>
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) to match against <c>Nodes</c>.</param>
/// <returns>The resolved <see cref="ClusterScope"/> decision for this node.</returns>
public static ClusterScope ResolveClusterScope(ReadOnlySpan<byte> blob, string nodeId)
{
@@ -133,7 +134,7 @@ public static class DeploymentArtifact
/// cluster's specs (or none when the node's row is absent).
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <param name="nodeId">The node's identity (e.g. "central-1:4053") used to resolve cluster scope.</param>
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) used to resolve cluster scope.</param>
/// <returns>The driver-instance specs this node should spawn.</returns>
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob, string nodeId)
{
@@ -149,7 +150,7 @@ public static class DeploymentArtifact
/// NOT a JSON string (number / bool / object / array) yields null rather than throwing
/// <see cref="InvalidOperationException"/> from <c>GetString()</c>. Keeps the artifact-decode lenient
/// — a wrong-typed field degrades to the field default / a skipped row, matching the documented
/// "malformed blobs return an empty list/composition" contract (Runtime-002).</summary>
/// "malformed blobs return an empty list/composition" contract.</summary>
private static string? ReadString(JsonElement el, string property) =>
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
@@ -190,6 +191,7 @@ public static class DeploymentArtifact
/// nodes.
/// </summary>
/// <param name="blob">The deployment artifact blob to parse.</param>
/// <returns>The projected <see cref="AddressSpaceComposition"/>, or an empty composition for empty/malformed blobs.</returns>
public static AddressSpaceComposition ParseComposition(ReadOnlySpan<byte> blob)
{
if (blob.IsEmpty) return Empty();
@@ -231,7 +233,7 @@ public static class DeploymentArtifact
/// area is in-cluster.
/// When <paramref name="onInconsistency"/> is supplied it is invoked with a human-readable message for each
/// kept driver-bound equipment whose owning UNS line is NOT in the node's cluster — a cross-cluster binding
/// that violates the same-cluster invariant (decision #122) and would orphan the equipment folder. This is
/// that violates the same-cluster invariant and would orphan the equipment folder. This is
/// detection only (observability); the equipment is still returned, since the upstream draft validator
/// is the authority that should prevent the binding in the first place.</summary>
/// <param name="blob">The deployment artifact blob.</param>
@@ -835,7 +837,7 @@ public static class DeploymentArtifact
private static EquipmentNode? ReadEquipmentNode(JsonElement el, IReadOnlyDictionary<string, string?> deviceHostById)
{
var id = ReadString(el, "EquipmentId");
// DisplayName = the UNS level-5 Name segment (friendly browse name, matching UnsArea/UnsLine
// DisplayName = the UNS Name segment (friendly browse name, matching UnsArea/UnsLine
// + the live rebuild's source of truth) — NOT the colloquial MachineCode. NodeId stays the
// logical EquipmentId.
var displayName = ReadString(el, "Name");
@@ -201,7 +201,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private IActorRef _mediator = null!;
/// <summary>
/// Routes an inbound operator write (resolved from the OPC UA node-manager side, Task 11) to the
/// Routes an inbound operator write (resolved from the OPC UA node-manager side) to the
/// owning driver child. <see cref="HandleRouteNodeWrite"/> gates on the local node being the
/// driver Primary, resolves the <see cref="_driverRefByNodeId"/> reverse map, and Asks the child a
/// <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <see cref="FullName"/>.
@@ -234,11 +234,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
private sealed record ChildEntry(IActorRef Actor, DriverInstanceSpec Spec, bool Stubbed)
{
// Convenience accessors for sites that don't need the full spec.
/// <summary>Gets the driver type name from the child's spec.</summary>
public string DriverType => Spec.DriverType;
/// <summary>Gets the last applied driver configuration JSON from the child's spec.</summary>
public string LastConfigJson => Spec.DriverConfig;
}
/// <inheritdoc />
/// <summary>Gets or sets the timer scheduler for this actor's config-db retry and rediscovery timers.</summary>
public ITimerScheduler Timers { get; set; } = null!;
public sealed class RetryConfigDbConnection
@@ -279,6 +281,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// ScriptedAlarm host instead of spawning a real <see cref="ScriptedAlarmHostActor"/> child, so
/// tests can intercept the <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/> message. Null
/// in production (the real host is spawned).</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
CommonsNodeId localNode,
@@ -676,7 +679,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <summary>
/// Partitions a multi-device driver's captured FixedTree by its (normalized) device-host folder segment
/// (<c>FolderPathSegments[1]</c>) and maps each device's subset under the candidate equipment whose
/// <see cref="EquipmentNode.DeviceHost"/> matches — the follow-up E part-2 multi-device graft. Returns
/// <see cref="EquipmentNode.DeviceHost"/> matches — the multi-device graft. Returns
/// the per-equipment plan map (one entry per device that matched AND had at least one new variable);
/// EMPTY when nothing is graftable.
/// <list type="bullet">
@@ -696,7 +699,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// regardless of case/whitespace.
/// <para><b>Warn-spam taming.</b> The unmatched/ambiguous/degenerate condition is warned ONCE then
/// Debug-logged on the repeated re-discovery passes (see <see cref="ShouldWarnPartition"/>).</para>
/// <para><b>Mid-connect partition shrink (M2).</b> If a later pass yields FEWER device partitions than a
/// <para><b>Mid-connect partition shrink.</b> If a later pass yields FEWER device partitions than a
/// prior pass within the same connect, the dropped partition's routes + materialised nodes are NOT
/// actively pruned until the next full redeploy (<see cref="PushDesiredSubscriptions"/> Clears + rebuilds
/// the maps). This matches the existing "FixedTree grows-then-stabilises within a connect" assumption —
@@ -995,7 +998,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
};
/// <summary>
/// Routes an inbound operator write (Task 11 Asks this from the OPC UA node-manager side) to the
/// Routes an inbound operator write (Asked from the OPC UA node-manager side) to the
/// owning driver child. Order matters:
/// <list type="number">
/// <item>PRIMARY gate FIRST — reuses the same <see cref="_localRole"/> redundancy signal the
@@ -1137,8 +1140,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_log.Debug("DriverHost {Node}: dropping native-alarm ack for {Node2} while Stale (config DB unreachable)",
_localNode, msg.ConditionNodeId));
// A driver child's post-connect DiscoveredNodesReady can't be injected while Stale (no composition is
// applied yet, so the equipment can't be resolved). Drop it — Task 6's re-discovery loop re-sends it
// and the Task-8 post-recovery re-apply self-heal it once an apply runs (matches the no-op drops above).
// applied yet, so the equipment can't be resolved). Drop it — the re-discovery loop re-sends it
// and the post-recovery re-apply self-heals it once an apply runs (matches the no-op drops above).
Receive<DriverInstanceActor.DiscoveredNodesReady>(_ => { });
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
@@ -1481,8 +1484,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// redeploy / re-apply (one that runs while the host is alive, so _discoveredByDriver is populated)
// would drop the injected FixedTree routes + materialised nodes until the driver happens to reconnect
// and re-discover. This loop is INERT on the bootstrap-restore path (RestoreApplied): there the actor
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from Task 6's
// post-connect re-discovery, NOT this re-apply. Re-resolve each cached driver's candidate equipments
// is freshly constructed so _discoveredByDriver is empty — restart survival comes from the
// post-connect re-discovery loop, NOT this re-apply. Re-resolve each cached driver's candidate equipments
// from the CURRENT composition (the SAME EquipmentNodes-UNION-EquipmentTags logic HandleDiscoveredNodes
// uses), then validate each cached (equipmentId → plan) entry PER ENTRY: drop the entry if its
// equipmentId is no longer a resolved candidate for the driver, OR the plan's NodeIds aren't scoped to
@@ -69,7 +69,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// Sets the set of references this driver should keep subscribed for the lifetime of the
/// current deployment. Unlike the one-shot <see cref="Subscribe"/>, the desired set is
/// retained and (re)established automatically every time the actor (re)enters
/// <c>Connected</c> — closing the F8b/#113 "re-subscribe across reconnects" gap and giving
/// <c>Connected</c> — closing the F8b "re-subscribe across reconnects" gap and giving
/// <see cref="DriverHostActor"/> a single message to drive the SubscribeBulk pass after an
/// apply. Sending an empty set clears the desired subscription.
/// </summary>
@@ -212,6 +212,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="rediscoverInterval">Optional interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
/// <param name="rediscoverMaxAttempts">Optional cap on re-discovery passes; defaults to 15.</param>
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
public static Props Props(
IDriver driver,
TimeSpan? reconnectInterval = null,
@@ -241,6 +242,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// </summary>
/// <param name="driverType">The type identifier of the driver.</param>
/// <param name="roles">Operational roles configured for this instance.</param>
/// <returns>True if the driver should start in DEV-STUB mode; otherwise false.</returns>
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
{
var isWindowsOnly = driverType is "Galaxy";
@@ -328,7 +330,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Receive<InitializeRequested>(msg => InitializeAsync(msg.DriverConfigJson));
// Fast-fail writes while still connecting — without this the inbound WriteAttribute dead-letters
// and DriverHostActor.HandleRouteNodeWrite waits its full 8s Ask before reporting a generic
// "write timeout". Synchronous Receive: Sender.Tell on the actor thread is safe (#4a-instance).
// "write timeout". Synchronous Receive: Sender.Tell on the actor thread is safe.
Receive<WriteAttribute>(_ =>
Sender.Tell(new WriteAttributeResult(false, "driver not connected")));
// An ack arriving while still connecting can't reach the upstream alarm system; drop it (the ack is
@@ -456,7 +458,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
{
Receive<RetryConnect>(_ => InitializeAsync(_currentConfigJson ?? "{}"));
// Fast-fail writes while reconnecting (same reason as Connecting — avoids the 8s host Ask
// timeout on an inbound write to a transiently-down driver). Synchronous Receive (#4a-instance).
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
Receive<WriteAttribute>(_ =>
Sender.Tell(new WriteAttributeResult(false, "driver not connected")));
// An ack arriving while reconnecting can't reach the upstream alarm system; drop it (fire-and-forget,
@@ -565,8 +567,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
var request = new[] { new WriteRequest(msg.TagId, msg.Value) };
// Bound the write so a hung backend can't pin this actor forever — decision #44/#45 keeps
// retry off by default, but a stalled call still needs an answer.
// Bound the write so a hung backend can't pin this actor forever — retry stays
// off by default, but a stalled call still needs an answer.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
@@ -23,6 +23,7 @@ public static class DriverSpawnPlanner
/// </summary>
/// <param name="current">The currently running driver children keyed by ID.</param>
/// <param name="target">The target driver instances from the deployment artifact.</param>
/// <returns>The computed <see cref="DriverSpawnPlan"/> with spawn, apply-delta, and stop sets.</returns>
public static DriverSpawnPlan Compute(
IReadOnlyDictionary<string, DriverChildSnapshot> current,
IReadOnlyList<DriverInstanceSpec> target)
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Health;
/// <summary>
/// Single-flight cached health probe against the ConfigDb. Reads cached state via
/// <c>Ask&lt;DbHealthStatus&gt;</c>; a single SELECT 1 runs at most every <c>RefreshInterval</c>.
/// Consumed by both the host's <c>/health/ready</c> endpoint (Task 54) and
/// Consumed by both the host's <c>/health/ready</c> endpoint and
/// <c>RedundancyStateActor</c>'s stale calc.
/// </summary>
public sealed class DbHealthProbeActor : ReceiveActor, IWithTimers
@@ -28,6 +28,7 @@ public sealed class DbHealthProbeActor : ReceiveActor, IWithTimers
/// <summary>Creates a Props instance for the DbHealthProbeActor.</summary>
/// <param name="dbFactory">The factory for creating ConfigDb contexts.</param>
/// <returns>The Akka <see cref="Akka.Actor.Props"/> for creating this actor.</returns>
public static Props Props(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) =>
Akka.Actor.Props.Create(() => new DbHealthProbeActor(dbFactory));
@@ -128,10 +128,7 @@ public sealed class HistorianAdapterActor : ReceiveActor
_ => AlarmSeverity.Critical,
};
/// <summary>Subscribes to the <c>redundancy-state</c> topic (so cluster role changes land as
/// <see cref="RedundancyStateChanged"/> and cache this node's role — the historize enqueue is gated to
/// the Primary so the alerts feed doesn't double-write across the warm-redundant pair) and to the
/// <c>alerts</c> topic (so live <see cref="AlarmTransitionEvent"/>s are translated + historized).</summary>
/// <inheritdoc />
protected override void PreStart()
{
_mediator = DistributedPubSub.Get(Context.System).Mediator;
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
/// <summary>
/// Single-threaded bridge between Akka messages and the OPC UA SDK address space. Hosted on
/// the pinned <c>opcua-synchronized-dispatcher</c> (Task 19 HOCON) so the OPC UA SDK sees
/// the pinned <c>opcua-synchronized-dispatcher</c> (HOCON) so the OPC UA SDK sees
/// only one thread per actor instance — its session/subscription locks expect strict
/// single-threaded access.
///
@@ -121,6 +121,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <returns>The configured <see cref="Props"/> for creating this actor, pinned to the
/// <see cref="DispatcherId"/> dispatcher.</returns>
public static Props Props(
IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null,
@@ -161,6 +163,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// <param name="healthTickInterval">The period between self-driven DB-health refresh ticks (each
/// Asks <paramref name="dbHealthProbe"/> for its cached status); defaults to 5 seconds. No timer is
/// started when <paramref name="dbHealthProbe"/> is null.</param>
/// <returns>The configured <see cref="Props"/> for creating this actor in tests, without dispatcher
/// pinning or the DPS subscribe.</returns>
public static Props PropsForTests(
IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null,
@@ -331,9 +335,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
var outcome = _applier.Apply(plan);
_lastApplied = composition;
// #85 — after the plan diff lands, rebuild the UNS folder hierarchy so OPC UA
// clients see Area/Line/Equipment as proper folders. Idempotent; AddressSpaceApplier
// skips folders that already exist with the same node id.
_applier.MaterialiseHierarchy(composition);
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
@@ -95,13 +95,11 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
return new DataValueSnapshot(Value: null, StatusCode: StatusBad, SourceTimestampUtc: null, ServerTimestampUtc: now);
}
// No-replay contract: a new subscriber does NOT receive a synthetic initial
// notification for the current cached value. Callers that need the current value must
// call ReadTag immediately after subscribing. The engine's cold-start path
// (startup-recovery + read-cache-refill) already does this.
/// <inheritdoc/>
/// <remarks>
/// <b>No-replay contract</b>: a new subscriber does NOT receive a synthetic initial
/// notification for the current cached value. Callers that need the current value must
/// call <see cref="ReadTag"/> immediately after subscribing. The engine's cold-start path
/// (startup-recovery + read-cache-refill) already does this.
/// </remarks>
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
{
ArgumentNullException.ThrowIfNull(path);
@@ -152,6 +150,10 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
private readonly string _path;
private readonly Action<string, DataValueSnapshot> _observer;
/// <summary>Creates a handle that deregisters <paramref name="observer"/> from <paramref name="owner"/> for <paramref name="path"/> on dispose.</summary>
/// <param name="owner">The upstream source to deregister from.</param>
/// <param name="path">The tag path the observer is subscribed to.</param>
/// <param name="observer">The observer callback to deregister.</param>
public Subscription(DependencyMuxTagUpstreamSource owner, string path, Action<string, DataValueSnapshot> observer)
{
_owner = owner;
@@ -159,6 +161,7 @@ public sealed class DependencyMuxTagUpstreamSource : ITagUpstreamSource
_observer = observer;
}
/// <summary>Deregisters the observer from the owning source. Idempotent — safe to call more than once.</summary>
public void Dispose()
{
// Swap _owner to null first so a double-dispose can't deregister twice (the
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
/// <para>
/// <b>ActiveState is NOT persisted</b> — the entity has no Active column. On
/// <see cref="LoadAsync"/> it is restored as <see cref="AlarmActiveState.Inactive"/>;
/// the engine re-derives it from the live predicate on startup (Phase 7 decision #14).
/// the engine re-derives it from the live predicate on startup.
/// </para>
/// <para>
/// <b>LastTransitionUtc ↔ UpdatedAtUtc</b>: the table has no dedicated transition
@@ -74,14 +74,6 @@ public sealed class EfAlarmConditionStateStore : IAlarmStateStore
}
/// <inheritdoc />
/// <remarks>
/// <b>Concurrency assumption</b>: saves for a given <c>alarmId</c> are serialized by the
/// owning host actor (one actor owns the engine per equipment). The check-then-insert
/// pattern is therefore safe under that guarantee — two concurrent inserts for the same
/// alarm cannot occur in the live
/// runtime. The <see cref="DbUpdateConcurrencyException"/> catch handles the edge case of a
/// racing concurrent restart during crash recovery.
/// </remarks>
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
using var db = await _dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
@@ -130,6 +130,7 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// <param name="localNode">The local cluster node id, used to read this node's <see cref="RedundancyRole"/>
/// from the <c>redundancy-state</c> topic so only the Primary publishes the cluster-wide <c>alerts</c>
/// transition. Null (the default) leaves the role unknown ⇒ default-emit (single-node deploys + tests).</param>
/// <returns>The <see cref="Akka.Actor.Props"/> used to instantiate the actor.</returns>
public static Props Props(
IActorRef publishActor,
IActorRef? mux,
@@ -37,12 +37,7 @@ public sealed class DpsScriptLogPublisher : IScriptLogPublisher
public DpsScriptLogPublisher(Func<ActorSystem> system) =>
_system = system ?? throw new ArgumentNullException(nameof(system));
/// <summary>
/// Publishes <paramref name="entry"/> onto the DPS <c>script-logs</c> topic. Any failure
/// (system not yet ready, mediator unavailable) is swallowed so the logging pipeline is
/// never disrupted by a transient cluster condition.
/// </summary>
/// <param name="entry">The entry to publish.</param>
/// <inheritdoc />
public void Publish(ScriptLogEntry entry)
{
try
@@ -43,6 +43,7 @@ public static class ServiceCollectionExtensions
/// Call this BEFORE <c>AddAkka</c>.
/// </summary>
/// <param name="services">The service collection to register with.</param>
/// <returns>The same <paramref name="services"/> instance for chaining.</returns>
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
{
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
@@ -189,6 +190,7 @@ public static class ServiceCollectionExtensions
/// </code>
/// </summary>
/// <param name="builder">The Akka configuration builder.</param>
/// <returns>The same <paramref name="builder"/> instance for chaining.</returns>
public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder)
{
// Production cluster HOCON (akka.conf) carries this dispatcher block, but consumers that
@@ -43,6 +43,7 @@ public sealed class VirtualTagActor : ReceiveActor
/// <param name="publisherFactory">Optional factory for creating DPS publishers.</param>
/// <param name="dependencyRefs">Optional list of dependency tag references; defaults to empty.</param>
/// <param name="mux">Optional reference to a dependency multiplexer actor.</param>
/// <returns>A configured <see cref="Akka.Actor.Props"/> for creating the actor.</returns>
public static Props Props(
string virtualTagId,
string expression,
@@ -58,6 +58,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
/// <param name="historyWriter">Sink for results whose plan has <c>Historize=true</c>. Null ⇒
/// <see cref="NullHistoryWriter.Instance"/> (no durable historian wired), so existing call sites
/// compile unchanged and never historize.</param>
/// <returns>The <see cref="Props"/> used to spawn a <see cref="VirtualTagHostActor"/>.</returns>
public static Props Props(IActorRef publishActor, IActorRef? mux, IVirtualTagEvaluator evaluator,
IHistoryWriter? historyWriter = null) =>
Akka.Actor.Props.Create(() => new VirtualTagHostActor(publishActor, mux, evaluator, historyWriter));
@@ -33,9 +33,14 @@ public sealed class AutoLoginAuthenticationHandler
=> _opts = disableLoginOptions.Value;
/// <summary>No-op: auto-login writes no cookie, so an explicit sign-in has nothing to persist.</summary>
/// <param name="user">The principal that would be signed in.</param>
/// <param name="properties">Authentication properties that would be persisted.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask;
/// <summary>No-op: there is no auth cookie to clear; the next request re-authenticates via this handler.</summary>
/// <param name="properties">Authentication properties that would be cleared.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask;
/// <inheritdoc />
@@ -65,7 +65,8 @@ public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvi
}
}
/// <inheritdoc />
/// <summary>Cancels the background ping loop, awaits its completion, and disposes the cancellation token source.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
_cts.Cancel();
@@ -132,7 +132,7 @@ public static class AuthEndpoints
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
if (!isForm) return Results.NoContent();
// Security-001: validate returnUrl is a relative (local) URL before redirecting.
// Validate returnUrl is a relative (local) URL before redirecting.
// An attacker can supply an absolute URL (e.g. https://evil.com) via a crafted link;
// LoginCard echoes the value verbatim into the hidden form field. Fall back to the
// app root rather than following the external URL (open-redirect prevention).
@@ -135,6 +135,7 @@ public sealed class JwtTokenService
/// required pairing whenever a JwtBearer scheme is wired.
/// </para>
/// </summary>
/// <returns>The <see cref="TokenValidationParameters"/> used to validate tokens minted by <see cref="Issue"/>.</returns>
public TokenValidationParameters BuildValidationParameters() => new()
{
ValidateIssuer = true,
@@ -34,7 +34,7 @@ public sealed class LdapOptions
/// Transport security for the LDAP connection — <see cref="LdapTransport.Ldaps"/> (implicit
/// TLS), <see cref="LdapTransport.StartTls"/> (upgrade), or <see cref="LdapTransport.None"/>
/// (plaintext, dev/test only — requires <see cref="AllowInsecure"/>). Replaces the former
/// <c>UseTls</c> bool (Task 1.4): <c>true</c>→<see cref="LdapTransport.Ldaps"/>,
/// <c>UseTls</c> bool: <c>true</c>→<see cref="LdapTransport.Ldaps"/>,
/// <c>false</c>→<see cref="LdapTransport.None"/>.
/// </summary>
public LdapTransport Transport { get; set; } = LdapTransport.None;
@@ -78,7 +78,7 @@ public sealed class LdapOptions
/// <summary>
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
/// role whose source group is in their membership list. Values are the canonical control-plane
/// roles (Task 1.7). Example dev mapping:
/// roles. Example dev mapping:
/// <code>"ReadOnly":"Viewer","ReadWrite":"Designer","AlarmAck":"Administrator"</code>
/// </summary>
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
@@ -90,6 +90,7 @@ public sealed class LdapOptions
/// handled by the app wrapper around the library service; <see cref="Enabled"/> is carried
/// through so the library's own feature gate stays consistent with the app master switch.
/// </summary>
/// <returns>The projected <see cref="LibLdapOptions"/> for the shared LDAP client library.</returns>
public LibLdapOptions ToLibraryOptions() => new()
{
Enabled = Enabled,
@@ -20,7 +20,13 @@ public sealed class OtOpcUaGroupRoleMapper(
IOptions<LdapOptions> ldapOptions,
ILdapGroupRoleMappingService dbMappings) : IGroupRoleMapper<string>
{
/// <inheritdoc />
/// <summary>Maps a user's LDAP group memberships to their OtOpcUa roles.</summary>
/// <param name="groups">The LDAP group names the user is a member of.</param>
/// <param name="ct">Cancellation token for the DB lookup.</param>
/// <returns>
/// The merged role mapping — the appsettings <c>GroupToRole</c> baseline unioned with
/// any matching system-wide DB grants, with a <c>null</c> <see cref="GroupRoleMapping{TRole}.Scope"/>.
/// </returns>
public async Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(groups);
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
/// <remarks>
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
/// DevStub result grants the canonical <c>"Administrator"</c> control-plane role (group
/// <c>"dev"</c>) so the dev session can navigate the full Admin UI (Task 1.7 renamed the prior
/// <c>"dev"</c>) so the dev session can navigate the full Admin UI (renamed from the prior
/// <c>"FleetAdmin"</c> to the canonical <c>"Administrator"</c>).
/// </remarks>
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
@@ -80,7 +80,7 @@ public sealed class OtOpcUaLdapAuthService : ILdapAuthService
{
// Dev bypass: accept any non-empty credentials and grant Administrator WITHOUT a real bind.
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
// survives the move to IGroupRoleMapper. (Task 1.7 canonicalized the role string from the
// survives the move to IGroupRoleMapper. (The role string was canonicalized from the
// prior "FleetAdmin" to "Administrator".)
_logger.LogWarning(
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
@@ -90,7 +90,7 @@ public sealed class OtOpcUaLdapAuthService : ILdapAuthService
// Fail closed on a plaintext transport unless explicitly opted in. The bespoke service
// enforced this at login (not startup), so the host still boots with an insecure-by-default
// config and only refuses the bind here — preserved verbatim after the UseTls→Transport
// migration (Task 1.4). The shared library's directory client does not re-check this.
// migration. The shared library's directory client does not re-check this.
if (_options.Transport == LdapTransport.None && !_options.AllowInsecure)
return new(false, null, username, [], [],
"Insecure LDAP is disabled. Enable a TLS transport or set AllowInsecure for dev/test.");
@@ -33,6 +33,7 @@ public static class RoleMapper
/// </summary>
/// <param name="baselineRoles">Roles already resolved from appsettings (or the dev stub).</param>
/// <param name="dbRows">LdapGroupRoleMapping rows for the user's groups (from GetByGroupsAsync).</param>
/// <returns>The merged, de-duplicated list of roles.</returns>
public static IReadOnlyList<string> Merge(
IReadOnlyCollection<string> baselineRoles,
IReadOnlyCollection<LdapGroupRoleMapping> dbRows)
@@ -33,6 +33,7 @@ public static class ServiceCollectionExtensions
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The application configuration root.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
@@ -148,7 +149,7 @@ public static class ServiceCollectionExtensions
// DriverOperator (policy NAME kept stable): may issue Reconnect/Restart commands against
// live driver instances from the Admin UI DriverStatusPanel. The role STRINGS it requires
// are the canonical control-plane roles (Task 1.7): Operator (was DriverOperator) and
// are the canonical control-plane roles: Operator (was DriverOperator) and
// Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings
// (e.g. "ot-driver-operator": "Operator").
o.AddPolicy("DriverOperator", policy =>