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.
16 KiB
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 flatIDictionary<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:
fieldPrefixparameter — form passes"Primary."/"Backup."so error messages disambiguate.LifetimeCount ≥ 3 × KeepAliveCountis 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— disambiguatesfor=attributes when the component appears twice.IsLegacy— toggles the warning banner.Errors(ValidationResult?) — drives per-field red text viaEndsWith("." + field)match againstValidationEntry.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, andFailover Retry Countstays in the backup subsection. - Code-behind:
_primaryConfigand_backupConfig(OpcUaEndpointConfiginstances),_primaryIsLegacy/_backupIsLegacyflags,_primaryErrors/_backupErrors(ValidationResult?). Save runs the validator on both, bails out on failure, serializes viaOpcUaEndpointConfigSerializer.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:
DeploymentManagerActorno longer parses JSON. It passes the rawPrimaryConfiguration/BackupConfigurationstrings straight to the factory.DataConnectionFactory.Create(OPC UA branch) callsOpcUaEndpointConfigSerializer.Deserialize(...), gets the typed model, and constructsOpcUaDataConnectionwith it.OpcUaDataConnection.cs:44-90is rewritten to takeOpcUaEndpointConfigdirectly. TheconnectionDetails.TryGetValue(...)ladder and theParseInt/ParseBoolhelpers go away. Heartbeat becomesif (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 atOpcUaEndpointConfigas the canonical schema is enough.
Verification
dotnet buildclean.dotnet testfor Commons + CentralUI + SiteRuntime/DCL — all green, including new tests.bash docker/deploy.sh— rebuild cluster.- 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.