diff --git a/docs/plans/2026-05-12-opcua-config-model-design.md b/docs/plans/2026-05-12-opcua-config-model-design.md new file mode 100644 index 0000000..46fb823 --- /dev/null +++ b/docs/plans/2026-05-12-opcua-config-model-design.md @@ -0,0 +1,293 @@ +# OPC UA Endpoint Config Model & Form Refactor — Design + +**Date**: 2026-05-12 +**Branch**: `feature/templates-folder-hierarchy` (and successors) +**Status**: Design approved, ready for implementation planning + +## Problem + +`DataConnection.PrimaryConfiguration` and `BackupConfiguration` are free-form JSON strings. Today: + +- The site-side runtime (`OpcUaDataConnection.cs:44-90`) parses them as a flat `IDictionary` and string-fishes ~12 keys (`endpoint` / `EndpointUrl`, `SessionTimeoutMs`, `SecurityMode`, `AutoAcceptUntrustedCerts`, etc.). +- The Central UI form (`DataConnectionForm.razor`) edits them as plain textareas. Its placeholder hints are inconsistent: `{"endpoint":"opc.tcp://..."}` for primary but `{"Host":"backup-host","Port":50101}` for backup — the latter is **not** actually parsed by the runtime. +- There is no schema, no validator, no documentation that's actually checked by code. +- The form's Protocol dropdown still offers "Custom" although no backend adapter exists — selecting it produces a deploy-time `"Unknown protocol type: Custom"` failure. + +We want a strongly-typed model for OPC UA endpoint configuration, a validator that's the single source of truth for what's legal, and a form that renders typed controls per field instead of a JSON blob. + +## Decision summary + +| # | Decision | Choice | +|---|----------|--------| +| 1 | Scope of the model | **Single source of truth** — used by both UI and runtime. Drops the dictionary-key string-fishing in `OpcUaDataConnection.cs`. | +| 2 | Field coverage in the form | **All fields, grouped**: Connection / Timing / Subscription / Heartbeat. Sensible defaults pre-filled. | +| 3 | Custom protocol option | **Remove from dropdown**. OPC UA is the only supported protocol today. | +| 4 | Storage format | **Typed nested JSON** via System.Text.Json with camelCase + `JsonStringEnumConverter`. | +| 5 | Model location | **`ScadaLink.Commons/Types/DataConnections/`** plus a sibling Validators/Serialization namespace. | +| 6 | Validator return type | **`ValidationResult` + `ValidationEntry`** — matches `SemanticValidator` convention. | +| 7 | Form structure | **Shared `` Blazor component**, used twice (primary + backup). | +| 8 | Protocol field in UI | **Hidden**; entity field set to `"OpcUa"` implicitly on save. | +| 9 | Validation timing | **On Save click only**. No live per-field validation. | +| 10 | Legacy-row handling | **Best-effort parse + warning banner**. Save rewrites to the new shape. | + +## Architecture + +``` +┌──────────────────────────────────────┐ +│ ScadaLink.Commons │ +│ Types/DataConnections/ │ +│ OpcUaEndpointConfig.cs (POCO) │ +│ OpcUaHeartbeatConfig.cs (POCO) │ +│ OpcUaSecurityMode.cs (enum) │ +│ Validators/ │ +│ OpcUaEndpointConfigValidator.cs │ +│ Serialization/ │ +│ OpcUaEndpointConfigSerializer.cs │ +└──────────────────────────────────────┘ + ▲ + │ (referenced by both) + ┌───────┴────────────────────────┐ + ▼ ▼ +┌──────────────────────────┐ ┌────────────────────────────┐ +│ ScadaLink.CentralUI │ │ ScadaLink.SiteRuntime │ +│ Components/Forms/ │ │ Actors/ │ +│ OpcUaEndpointEditor │ │ DeploymentManagerActor │ +│ .razor (shared) │ │ (passes raw JSON to │ +│ │ │ DataConnectionFactory)│ +│ Pages/Admin/ │ │ │ +│ DataConnectionForm │ │ DataConnections.OpcUa/ │ +│ .razor │ │ OpcUaDataConnection.cs │ +└──────────────────────────┘ │ (consumes typed model) │ + └────────────────────────────┘ +``` + +Both sides deserialize from `DataConnection.PrimaryConfiguration` / `BackupConfiguration` strings into the same `OpcUaEndpointConfig` instance. The DB column type does not change. + +## The model + +```csharp +// ScadaLink.Commons/Types/DataConnections/OpcUaEndpointConfig.cs +namespace ScadaLink.Commons.Types.DataConnections; + +public sealed class OpcUaEndpointConfig +{ + // Connection + public string EndpointUrl { get; set; } = ""; + public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None; + public bool AutoAcceptUntrustedCerts { get; set; } = true; + + // Timing + public int SessionTimeoutMs { get; set; } = 60000; + public int OperationTimeoutMs { get; set; } = 15000; + + // Subscription + public int PublishingIntervalMs { get; set; } = 1000; + public int SamplingIntervalMs { get; set; } = 1000; + public int QueueSize { get; set; } = 10; + public int KeepAliveCount { get; set; } = 10; + public int LifetimeCount { get; set; } = 30; + public int MaxNotificationsPerPublish { get; set; } = 100; + + // Heartbeat (optional) + public OpcUaHeartbeatConfig? Heartbeat { get; set; } +} + +public sealed class OpcUaHeartbeatConfig +{ + public string TagPath { get; set; } = ""; + public int MaxSilenceSeconds { get; set; } = 30; +} + +public enum OpcUaSecurityMode { None, Sign, SignAndEncrypt } +``` + +Defaults match the runtime's current fallbacks so a default-constructed config equals the empty/missing-JSON case. Settable properties (not `init`) so the form can `@bind` directly. + +## The validator + +```csharp +// ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs +public static class OpcUaEndpointConfigValidator +{ + public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "") + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.EndpointUrl)) + errors.Add(Err("EndpointUrl", "Endpoint URL is required.")); + else if (!Uri.TryCreate(config.EndpointUrl, UriKind.Absolute, out var uri) + || uri.Scheme != "opc.tcp") + errors.Add(Err("EndpointUrl", + "Endpoint URL must be a valid opc.tcp:// URI.")); + + if (config.SessionTimeoutMs <= 0) + errors.Add(Err("SessionTimeoutMs", "Must be > 0.")); + if (config.OperationTimeoutMs <= 0) + errors.Add(Err("OperationTimeoutMs", "Must be > 0.")); + if (config.PublishingIntervalMs <= 0) + errors.Add(Err("PublishingIntervalMs", "Must be > 0.")); + if (config.SamplingIntervalMs <= 0) + errors.Add(Err("SamplingIntervalMs", "Must be > 0.")); + if (config.QueueSize < 1) + errors.Add(Err("QueueSize", "Must be ≥ 1.")); + if (config.KeepAliveCount < 1) + errors.Add(Err("KeepAliveCount", "Must be ≥ 1.")); + if (config.LifetimeCount < config.KeepAliveCount * 3) + errors.Add(Err("LifetimeCount", + "Must be at least 3× KeepAliveCount per OPC UA spec.")); + if (config.MaxNotificationsPerPublish < 1) + errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1.")); + + if (config.Heartbeat is { } hb) + { + if (string.IsNullOrWhiteSpace(hb.TagPath)) + errors.Add(Err("Heartbeat.TagPath", + "Tag path is required when heartbeat is enabled.")); + if (hb.MaxSilenceSeconds <= 0) + errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0.")); + } + + return errors.Count == 0 + ? ValidationResult.Success() + : ValidationResult.FromErrors(errors); + + ValidationEntry Err(string field, string msg) => + new(Field: $"{fieldPrefix}{field}", + Message: msg, + Category: ValidationCategory.Schema); + } +} +``` + +Key points: +- `fieldPrefix` parameter — form passes `"Primary."` / `"Backup."` so error messages disambiguate. +- `LifetimeCount ≥ 3 × KeepAliveCount` is an actual OPC UA spec constraint and exemplifies the "domain knowledge in the validator" win. +- Static, pure, no DI — trivial to unit-test. + +## Serialization & legacy fallback + +```csharp +// ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs +public static class OpcUaEndpointConfigSerializer +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + public static string Serialize(OpcUaEndpointConfig config) + => JsonSerializer.Serialize(config, JsonOpts); + + public static (OpcUaEndpointConfig Config, bool IsLegacy) Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return (new OpcUaEndpointConfig(), false); + + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("endpointUrl", out _)) + return (JsonSerializer.Deserialize(json, JsonOpts)!, false); + } + catch (JsonException) { /* fall through */ } + + return (LoadLegacy(json), IsLegacy: true); + } + + private static OpcUaEndpointConfig LoadLegacy(string json) + { + var dict = JsonSerializer.Deserialize>(json) + ?? new(); + var c = new OpcUaEndpointConfig + { + EndpointUrl = dict.GetValueOrDefault("endpoint") + ?? dict.GetValueOrDefault("EndpointUrl") ?? "", + SecurityMode = Enum.TryParse( + dict.GetValueOrDefault("SecurityMode"), out var sm) ? sm : OpcUaSecurityMode.None, + AutoAcceptUntrustedCerts = ParseBool(dict, "AutoAcceptUntrustedCerts", true), + SessionTimeoutMs = ParseInt(dict, "SessionTimeoutMs", 60000), + OperationTimeoutMs = ParseInt(dict, "OperationTimeoutMs", 15000), + PublishingIntervalMs = ParseInt(dict, "PublishingIntervalMs", 1000), + SamplingIntervalMs = ParseInt(dict, "SamplingIntervalMs", 1000), + QueueSize = ParseInt(dict, "QueueSize", 10), + KeepAliveCount = ParseInt(dict, "KeepAliveCount", 10), + LifetimeCount = ParseInt(dict, "LifetimeCount", 30), + MaxNotificationsPerPublish = ParseInt(dict, "MaxNotificationsPerPublish", 100) + }; + var hbPath = dict.GetValueOrDefault("HeartbeatTagPath"); + if (!string.IsNullOrWhiteSpace(hbPath)) + c.Heartbeat = new OpcUaHeartbeatConfig + { + TagPath = hbPath, + MaxSilenceSeconds = ParseInt(dict, "HeartbeatMaxSilence", 30) + }; + return c; + } +} +``` + +`Deserialize` returns `(Config, IsLegacy)`. The form raises a Bootstrap warning banner when `IsLegacy=true`. On Save we always `Serialize` — the row gets rewritten to the new shape and the banner disappears on next edit. + +## The shared Blazor component + +`src/ScadaLink.CentralUI/Components/Forms/OpcUaEndpointEditor.razor` + +Parameters: +- `Config` (`[EditorRequired]`) — bound by reference; parent owns the instance. +- `Title` — header text (e.g. "Primary Endpoint"). +- `IdPrefix` — disambiguates `for=` attributes when the component appears twice. +- `IsLegacy` — toggles the warning banner. +- `Errors` (`ValidationResult?`) — drives per-field red text via `EndsWith("." + field)` match against `ValidationEntry.Field`. + +Rendering: four section labels (Connection, Timing, Subscription, Heartbeat) with Bootstrap `row g-2` grids. Heartbeat starts collapsed behind an "Enable Heartbeat" button; once shown it has a "Remove Heartbeat" button. Per-field error text appears immediately below each control. + +## DataConnectionForm changes + +- **Removed**: Protocol `