Files
ScadaBridge/docs/plans/2026-05-12-opcua-config-model-design.md
T
Joseph Doherty 43228185b4 docs: convert standard diagrams from draw.io PNGs to inline Mermaid
Gitea renders mermaid inline, so the flow/state/hierarchy/DAG diagrams
move to text-in-markdown: auto-layout (removes the manual overlap-prone
draw.io step), diffable source, no committed binaries, and a dark-text
theme so labels stay legible. Keep draw.io PNGs only for the two complex
bespoke diagrams (logical architecture, env2 topology) where pixel
control still wins. All 24 mermaid blocks validated by rendering.
2026-06-01 00:23:00 -04:00

16 KiB
Raw Blame History

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<string,string> 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 ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/ plus a sibling Validators/Serialization namespace.
6 Validator return type ValidationResult + ValidationEntry — matches SemanticValidator convention.
7 Form structure Shared <OpcUaEndpointEditor> 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

%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
    subgraph COMMONS["ZB.MOM.WW.ScadaBridge.Commons"]
        TYPES["Types/DataConnections/<br/>OpcUaEndpointConfig.cs (POCO)<br/>OpcUaHeartbeatConfig.cs (POCO)<br/>OpcUaSecurityMode.cs (enum)"]
        VALID["Validators/<br/>OpcUaEndpointConfigValidator.cs"]
        SER["Serialization/<br/>OpcUaEndpointConfigSerializer.cs"]
        TYPES ~~~ VALID ~~~ SER
    end

    subgraph CENTRALUI["ZB.MOM.WW.ScadaBridge.CentralUI"]
        CUIFORMS["Components/Forms/<br/>OpcUaEndpointEditor.razor (shared)"]
        CUIPAGES["Pages/Admin/<br/>DataConnectionForm.razor"]
        CUIFORMS ~~~ CUIPAGES
    end

    subgraph SITERUNTIME["ZB.MOM.WW.ScadaBridge.SiteRuntime"]
        SRACTORS["Actors/<br/>DeploymentManagerActor<br/>(passes raw JSON to DataConnectionFactory)"]
        SRDC["DataConnections.OpcUa/<br/>OpcUaDataConnection.cs<br/>(consumes typed model)"]
        SRACTORS ~~~ SRDC
    end

    COMMONS -->|referenced by| CENTRALUI
    COMMONS -->|referenced by| SITERUNTIME

    NOTE["Both sides deserialize DataConnection.PrimaryConfiguration / BackupConfiguration<br/>into the same OpcUaEndpointConfig instance. The DB column type does not change."]
    CENTRALUI -.- NOTE
    SITERUNTIME -.- NOTE

    classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
    classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
    classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
    classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
    classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
    class COMMONS dec
    class TYPES,VALID,SER warn
    class CENTRALUI,CUIFORMS,CUIPAGES proc
    class SITERUNTIME,SRACTORS,SRDC start
    class NOTE muted

Both sides deserialize from DataConnection.PrimaryConfiguration / BackupConfiguration strings into the same OpcUaEndpointConfig instance. The DB column type does not change.

The model

// ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/OpcUaEndpointConfig.cs
namespace ZB.MOM.WW.ScadaBridge.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

// ZB.MOM.WW.ScadaBridge.Commons/Validators/OpcUaEndpointConfigValidator.cs
public static class OpcUaEndpointConfigValidator
{
    public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "")
    {
        var errors = new List<ValidationEntry>();

        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

// ZB.MOM.WW.ScadaBridge.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<OpcUaEndpointConfig>(json, JsonOpts)!, false);
        }
        catch (JsonException) { /* fall through */ }

        return (LoadLegacy(json), IsLegacy: true);
    }

    private static OpcUaEndpointConfig LoadLegacy(string json)
    {
        var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
                   ?? new();
        var c = new OpcUaEndpointConfig
        {
            EndpointUrl = dict.GetValueOrDefault("endpoint")
                       ?? dict.GetValueOrDefault("EndpointUrl") ?? "",
            SecurityMode = Enum.TryParse<OpcUaSecurityMode>(
                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/ZB.MOM.WW.ScadaBridge.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 <select>, the JSON <textarea> for primary, the JSON <textarea> for backup.
  • Added: Two <OpcUaEndpointEditor> instances. The backup one is still gated behind "Add Backup Endpoint" / "Remove Backup" buttons, and Failover Retry Count stays in the backup subsection.
  • Code-behind: _primaryConfig and _backupConfig (OpcUaEndpointConfig instances), _primaryIsLegacy/_backupIsLegacy flags, _primaryErrors/_backupErrors (ValidationResult?). Save runs the validator on both, bails out on failure, serializes via OpcUaEndpointConfigSerializer.Serialize.
  • Protocol on the entity is set to the literal "OpcUa" on create. The column stays so the runtime's protocol-dispatch (DataConnectionFactory) is untouched.

Runtime parser swap

src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:426-456 — today this code parses both JSON strings into Dictionary<string, string> and hands the dict to DataConnectionFactory.

After the change:

  • DeploymentManagerActor no longer parses JSON. It passes the raw PrimaryConfiguration / BackupConfiguration strings straight to the factory.
  • DataConnectionFactory.Create (OPC UA branch) calls OpcUaEndpointConfigSerializer.Deserialize(...), gets the typed model, and constructs OpcUaDataConnection with it.
  • OpcUaDataConnection.cs:44-90 is rewritten to take OpcUaEndpointConfig directly. The connectionDetails.TryGetValue(...) ladder and the ParseInt / ParseBool helpers go away. Heartbeat becomes if (cfg.Heartbeat is { } hb) { ... }.

Pre-refactor deployment artifacts still load: the serializer's legacy-dict fallback handles them. IsLegacy is discarded by the runtime (only the form cares).

Tests

Project New / changed tests
ZB.MOM.WW.ScadaBridge.Commons.Tests OpcUaEndpointConfigSerializerTests: typed-JSON roundtrip preserves all fields; legacy flat-dict deserializes correctly and sets IsLegacy=true; empty/null JSON returns defaults; unknown JSON shape falls back cleanly.
ZB.MOM.WW.ScadaBridge.Commons.Tests OpcUaEndpointConfigValidatorTests: missing URL → error; bad scheme → error; LifetimeCount < 3×KeepAliveCount → error; heartbeat-enabled-but-no-tag-path → error; valid config → IsValid=true; fieldPrefix applied to every error's Field.
ZB.MOM.WW.ScadaBridge.CentralUI.Tests OpcUaEndpointEditorTests (bUnit): renders all grouped sections; binding mutates the passed Config; Enable/Remove Heartbeat toggles the sub-object; passing Errors renders per-field red text; IsLegacy=true shows the warning banner.
ZB.MOM.WW.ScadaBridge.CentralUI.Tests DataConnectionsFormTests (bUnit, add if missing): Save with invalid primary URL → no navigation, validator error shown; Save with valid config → repo AddDataConnectionAsync called with Protocol="OpcUa" and JSON containing "endpointUrl" in camelCase.
Site/DCL test project Update existing tests to construct OpcUaDataConnection from OpcUaEndpointConfig instead of IDictionary<string,string>.

Out of scope

  • No EF Core migration; the legacy-parse path handles pre-existing rows.
  • No new protocols. Custom dropdown option is removed. If/when a second protocol lands, the form re-introduces a protocol dropdown and a if (Protocol == "OpcUa") branch around the editor component.
  • No live (debounced) validation.
  • No certificate management UI beyond AutoAcceptUntrustedCerts.
  • No "Verify endpoint" button.
  • No rewrite of docs/requirements/Component-DataConnectionLayer.md — a short note pointing at OpcUaEndpointConfig as the canonical schema is enough.

Verification

  1. dotnet build clean.
  2. dotnet test for Commons + CentralUI + SiteRuntime/DCL — all green, including new tests.
  3. bash docker/deploy.sh — rebuild cluster.
  4. Browser smoke at http://localhost:9000/admin/connections:
    • New connection via site context menu → form shows the OPC UA endpoint editor; no Protocol dropdown.
    • Bad URL → Save → red error under Endpoint URL.
    • Valid config, toggle heartbeat, set timing knobs → Save → row created; reload → fields round-trip.
    • Edit a pre-refactor row → warning banner appears, fields populated from legacy dict; Save rewrites; second edit no banner.
    • Add backup endpoint, save, deploy a template that uses the connection → site logs show primary online and failover settings honored.