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).