19 bite-sized tasks across adapter (TDD), config serializer/validator, browse generalization rename, Central UI protocol selector/editor, packaging, and integration. Co-located task persistence for resumable execution.
54 KiB
MxGateway Data Connection Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Add a second data-connection protocol, MxGateway, alongside OPC UA — a clean tag-value pipe (read/subscribe/write) plus Galaxy hierarchy browse, with optional second-endpoint failover.
Architecture: A new MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection adapter registered behind the existing DataConnectionFactory extension point. The adapter talks to the MxAccess Gateway through an IMxGatewayClient seam (testable with a fake; the real impl wraps the ZB.MOM.WW.MxGateway.Client NuGet package and GalaxyRepositoryClient). The OPC-UA-named browse plumbing is renamed to protocol-agnostic names so both protocols share one tag-picker path. No entity/schema changes — primary/backup failover already lives on the DataConnection entity.
Tech Stack: .NET 10, Akka.NET (Become/Stash actors), gRPC (ZB.MOM.WW.MxGateway.Client + …Contracts from the Gitea feed), Blazor Server + Bootstrap, xUnit + FluentAssertions, central NuGet package management.
Design doc: docs/plans/2026-05-28-mxgateway-data-connection-design.md
Reference skills: Use @superpowers-extended-cc:test-driven-development for every adapter/serializer task. Use @frontend-design for the two .razor UI tasks.
Key facts the implementer needs
- Extension point:
DataConnectionFactory(src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs) registers protocols viaRegisterAdapter("OpcUa", details => …)in its constructor. Add one line for"MxGateway". - Config flow: stored config JSON →
DeploymentManagerActor.FlattenConnectionConfig(protocol, json)(src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:759) →IDictionary<string,string>→DataConnectionActor→adapter.ConnectAsync(details). The actor never knows the protocol's config shape; the dict is the contract.FlattenConnectionConfigcurrently branches on"OpcUa"and falls back to a generic flat-dict parse for unknown protocols — add an"MxGateway"arm. IDataConnectioncontract:src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IDataConnection.cs. Methods:ConnectAsync,DisconnectAsync,SubscribeAsync(tagPath, SubscriptionCallback, ct)→string,UnsubscribeAsync(id),ReadAsync→ReadResult,ReadBatchAsync→IReadOnlyDictionary<string,ReadResult>,WriteAsync→WriteResult,WriteBatchAsync,WriteBatchAndWaitAsync→bool,Status(ConnectionHealth),event Action? Disconnected. Value type:TagValue(object? Value, QualityCode Quality, DateTimeOffset Timestamp);QualityCode { Good, Bad, Uncertain }.IBrowsableDataConnection:src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs—BrowseChildrenAsync(string? parentNodeId, ct)→BrowseChildrenResult(IReadOnlyList<BrowseNode>, bool Truncated).BrowseNode(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren);BrowseNodeClass { Object, Variable, Method, Other }. ThrowConnectionNotConnectedExceptionwhen no live session. These record/interface names do NOT change in the rename — only the OPC-UA-named command/service/dialog layer does.- OPC UA adapter to mirror:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs. Note the_disconnectFiredInterlocked.Exchangeonce-only guard pattern — replicate it. - MxGateway client API (in
~/Desktop/MxAccessGateway/clients/dotnet):MxGatewayClient.Create(MxGatewayClientOptions)→OpenSessionAsync()→MxGatewaySession. Session:RegisterAsync(clientName)→int serverHandle;AddItemAsync(serverHandle, itemDef)→int itemHandle;AdviseAsync(serverHandle, itemHandle);UnAdviseAsync/RemoveItemAsync;SubscribeBulkAsync/UnsubscribeBulkAsync;ReadBulkAsync(serverHandle, tagAddresses, timeout)→IReadOnlyList<BulkReadResult>;WriteBulkAsync(serverHandle, IReadOnlyList<WriteBulkEntry>)→IReadOnlyList<BulkWriteResult>;StreamEventsAsync(afterWorkerSequence)→IAsyncEnumerable<MxEvent>. Browse:GalaxyRepositoryClient.Create(options)→BrowseChildrenRawAsync(BrowseChildrenRequest)→BrowseChildrenReply. Value helpers:MxValueExtensions.ToMxValue(...),.ToClrValue();MxStatusProxyExtensions.IsSuccess(). MxEventfields:family(MxEventFamily),item_handle,value(MxValue),quality(int, OPC-style; ≥192 = good),source_timestamp(google.protobuf.Timestamp),worker_sequence(uint64),bodyoneof incl.on_data_change.MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE = 1.BulkReadResult:tag_address,item_handle,was_successful,was_cached,value(MxValue),quality(int),source_timestamp,statuses,error_message.BulkWriteResult:item_handle,was_successful,hresult?,statuses,error_message.BrowseChildrenReply:children(repeated GalaxyObject),next_page_token,total_child_count,child_has_children(repeated bool).GalaxyObject:gobject_id,tag_name,contained_name,parent_gobject_id,is_area,attributes(repeated GalaxyAttribute).GalaxyAttribute:attribute_name,full_tag_reference,data_type_name,is_array,is_historized,is_alarm.BrowseChildrenRequestparent oneof:parent_gobject_id|parent_tag_name|parent_contained_path; pluspage_size,include_attributes.- Exceptions:
MxGatewaySessionException,MxGatewayAuthenticationException,MxGatewayAuthorizationException,MxGatewayWorkerException,MxGatewayCommandException,MxAccessException— all derive fromMxGatewayException. - Central package management: versions go in
Directory.Packages.props(<PackageVersion>); projects reference<PackageReference Include="…" />with no version.
Task 1: Packaging foundation — Gitea feed + package refs
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 13 (rename track)
Files:
- Create:
nuget.config(repo root) - Modify:
Directory.Packages.props - Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
Step 1: Create nuget.config at repo root
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
</packageSources>
<!-- Credentials are NOT committed. Provide them per-developer via:
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
--name dohertj2-gitea --username <user> --password <token> --store-password-in-clear-text
or NuGet env vars in CI / the docker build (see docker/deploy.sh wiring in Task 19). -->
</configuration>
Step 2: Add package versions to Directory.Packages.props (under the existing <ItemGroup>; check the published version with dotnet package search ZB.MOM.WW.MxGateway.Client --source dohertj2-gitea — README references 0.1.0, use the latest available):
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
Step 3: Add the PackageReference to the DCL csproj (in the first <ItemGroup>, after the OPC UA reference):
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
(…Contracts comes in transitively.)
Step 4: Restore and verify
Run: dotnet restore ZB.MOM.WW.ScadaBridge.slnx
Expected: restore succeeds, the MxGateway packages resolve from the Gitea feed. If it fails with 401, the developer must add feed credentials (see the comment in nuget.config) — surface that, don't hardcode a token.
Step 5: Commit
git add nuget.config Directory.Packages.props src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj
git commit -m "build(dcl): add Gitea feed + ZB.MOM.WW.MxGateway.Client package reference"
Task 2: MxGatewayEndpointConfig type
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 13, Task 14
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs
Step 1: Write the type (mirrors OpcUaEndpointConfig — a mutable POCO with defaults; fields per the design's config table):
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
/// <summary>
/// Per-endpoint configuration for an MxGateway data connection. Serialized to the
/// typed JSON shape stored in <c>DataConnection.PrimaryConfiguration</c> /
/// <c>BackupConfiguration</c>. Both primary and backup use this same shape — the
/// backup is simply a second gateway endpoint for failover.
/// </summary>
public class MxGatewayEndpointConfig
{
/// <summary>Gateway base URL (e.g. "http://localhost:5000").</summary>
public string Endpoint { get; set; } = "http://localhost:5000";
/// <summary>API key sent to the gateway as <c>authorization: Bearer <key></c>.</summary>
public string ApiKey { get; set; } = "";
/// <summary>MXAccess client registration name. Blank → derive "scadabridge-<connName>" at connect time.</summary>
public string ClientName { get; set; } = "";
/// <summary>MXAccess user id applied to every write-back. 0 = no user context.</summary>
public int WriteUserId { get; set; }
/// <summary>Use TLS to a secured gateway.</summary>
public bool UseTls { get; set; }
/// <summary>Path to the CA certificate (TLS only).</summary>
public string CaFile { get; set; } = "";
/// <summary>TLS server-name override.</summary>
public string ServerName { get; set; } = "";
/// <summary>ReadBulk per-call timeout in milliseconds.</summary>
public int ReadTimeoutMs { get; set; } = 5000;
}
Step 2: Build the Commons project
Run: dotnet build src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj
Expected: PASS.
Step 3: Commit
git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs
git commit -m "feat(commons): add MxGatewayEndpointConfig type"
Task 3: MxGatewayEndpointConfigSerializer + tests
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4, Task 13, Task 14
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Serialization/MxGatewayEndpointConfigSerializerTests.cs
This is simpler than the OPC UA serializer — MxGateway is net-new, so there is no legacy flat-dict shape to fall back to. Provide Serialize, Deserialize (typed-or-default), ToFlatDict, FromFlatDict. (Locate the existing OPC UA serializer tests under tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Serialization/ and mirror their style.)
Step 1: Write the failing test
using FluentAssertions;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Serialization;
public class MxGatewayEndpointConfigSerializerTests
{
[Fact]
public void Serialize_then_Deserialize_round_trips_all_fields()
{
var cfg = new MxGatewayEndpointConfig
{
Endpoint = "https://gw:5001", ApiKey = "k", ClientName = "c",
WriteUserId = 7, UseTls = true, CaFile = "/ca.pem",
ServerName = "gw.local", ReadTimeoutMs = 1234
};
var json = MxGatewayEndpointConfigSerializer.Serialize(cfg);
var back = MxGatewayEndpointConfigSerializer.Deserialize(json);
back.Should().BeEquivalentTo(cfg);
}
[Fact]
public void Deserialize_null_or_blank_returns_default()
=> MxGatewayEndpointConfigSerializer.Deserialize(null).Endpoint
.Should().Be(new MxGatewayEndpointConfig().Endpoint);
[Fact]
public void ToFlatDict_FromFlatDict_round_trips()
{
var cfg = new MxGatewayEndpointConfig { Endpoint = "http://x", ApiKey = "k", WriteUserId = 3, ReadTimeoutMs = 999 };
var dict = MxGatewayEndpointConfigSerializer.ToFlatDict(cfg);
var back = MxGatewayEndpointConfigSerializer.FromFlatDict(dict);
back.Should().BeEquivalentTo(cfg);
}
[Fact]
public void FromFlatDict_invalid_numeric_falls_back_to_default()
{
var back = MxGatewayEndpointConfigSerializer.FromFlatDict(
new Dictionary<string, string> { ["ReadTimeoutMs"] = "not-a-number" });
back.ReadTimeoutMs.Should().Be(new MxGatewayEndpointConfig().ReadTimeoutMs);
}
}
Step 2: Run to verify it fails
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ --filter MxGatewayEndpointConfigSerializerTests
Expected: FAIL (type does not exist).
Step 3: Write the serializer
using System.Globalization;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
namespace ZB.MOM.WW.ScadaBridge.Commons.Serialization;
/// <summary>
/// Serializes <see cref="MxGatewayEndpointConfig"/> to/from the typed JSON stored in
/// <c>DataConnection.PrimaryConfiguration</c> / <c>BackupConfiguration</c>, and flattens
/// it to the <c>IDictionary<string,string></c> shape <c>IDataConnection.ConnectAsync</c>
/// expects. MxGateway is net-new, so there is no legacy shape to recover.
/// </summary>
public static class MxGatewayEndpointConfigSerializer
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
public static string Serialize(MxGatewayEndpointConfig config)
=> JsonSerializer.Serialize(config, JsonOpts);
public static MxGatewayEndpointConfig Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new MxGatewayEndpointConfig();
try { return JsonSerializer.Deserialize<MxGatewayEndpointConfig>(json, JsonOpts) ?? new MxGatewayEndpointConfig(); }
catch (JsonException) { return new MxGatewayEndpointConfig(); }
}
public static IDictionary<string, string> ToFlatDict(MxGatewayEndpointConfig c) => new Dictionary<string, string>
{
["Endpoint"] = c.Endpoint,
["ApiKey"] = c.ApiKey,
["ClientName"] = c.ClientName,
["WriteUserId"] = c.WriteUserId.ToString(CultureInfo.InvariantCulture),
["UseTls"] = c.UseTls.ToString(),
["CaFile"] = c.CaFile,
["ServerName"] = c.ServerName,
["ReadTimeoutMs"] = c.ReadTimeoutMs.ToString(CultureInfo.InvariantCulture),
};
public static MxGatewayEndpointConfig FromFlatDict(IDictionary<string, string> d)
{
var c = new MxGatewayEndpointConfig();
if (d.TryGetValue("Endpoint", out var ep) && !string.IsNullOrWhiteSpace(ep)) c.Endpoint = ep;
if (d.TryGetValue("ApiKey", out var ak)) c.ApiKey = ak;
if (d.TryGetValue("ClientName", out var cn)) c.ClientName = cn;
if (d.TryGetValue("WriteUserId", out var wu) && int.TryParse(wu, out var wuv)) c.WriteUserId = wuv;
if (d.TryGetValue("UseTls", out var tls) && bool.TryParse(tls, out var tlsv)) c.UseTls = tlsv;
if (d.TryGetValue("CaFile", out var ca)) c.CaFile = ca;
if (d.TryGetValue("ServerName", out var sn)) c.ServerName = sn;
if (d.TryGetValue("ReadTimeoutMs", out var rt) && int.TryParse(rt, out var rtv)) c.ReadTimeoutMs = rtv;
return c;
}
}
Step 4: Run tests to verify they pass
Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/ --filter MxGatewayEndpointConfigSerializerTests
Expected: PASS (4 tests).
Step 5: Commit
git add src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Serialization/MxGatewayEndpointConfigSerializerTests.cs
git commit -m "feat(commons): MxGatewayEndpointConfig serializer + tests"
Task 4: MxGatewayEndpointConfigValidator + tests
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 3, Task 13, Task 14
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.Commons/Validators/MxGatewayEndpointConfigValidator.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs
Mirror OpcUaEndpointConfigValidator (returns a list of error strings, prefixed). Rules: Endpoint required + must be an absolute http(s) URI; ApiKey required; ReadTimeoutMs > 0; if UseTls and CaFile set, CaFile must be non-blank (warn only if blank — TLS can use system roots). Read the existing validator first to match the exact signature (likely static IReadOnlyList<string> Validate(MxGatewayEndpointConfig cfg, string prefix)).
Step 1: Write failing tests (valid config → no errors; blank Endpoint → error; blank ApiKey → error; ReadTimeoutMs 0 → error). Step 2: Run, verify FAIL. Step 3: Implement. Step 4: Run, verify PASS. Step 5: Commit feat(commons): MxGatewayEndpointConfig validator + tests.
Task 5: Client seam interfaces + MxGatewayGlobalOptions
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 13, Task 14
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs - Create:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/MxGatewayGlobalOptions.cs
The seam uses neutral DTOs (no generated protobuf types) so the adapter and its tests never touch the NuGet package — the real impl (Task 11) translates. This is the same pattern as IOpcUaClient.
Step 1: Write IMxGatewayClient.cs
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>Connection parameters resolved from the flat config dict.</summary>
public record MxGatewayConnectionOptions(
string Endpoint, string ApiKey, string ClientName, int WriteUserId,
bool UseTls, string? CaFile, string? ServerName, int ReadTimeoutMs);
/// <summary>One advised-tag value change pushed from the gateway event stream.</summary>
public record MxValueUpdate(string TagPath, object? Value, QualityCode Quality, DateTimeOffset Timestamp);
/// <summary>Per-tag read outcome.</summary>
public record MxReadOutcome(string TagPath, bool Success, object? Value, QualityCode Quality, DateTimeOffset Timestamp, string? Error);
/// <summary>Per-tag write outcome.</summary>
public record MxWriteOutcome(string TagPath, bool Success, string? Error);
/// <summary>One node in a Galaxy browse level.</summary>
public record MxBrowseChild(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren);
/// <summary>
/// Seam over the MxAccess Gateway .NET client + Galaxy repository client. Decouples
/// <see cref="MxGatewayDataConnection"/> from the generated gRPC/protobuf types so the
/// adapter is unit-testable with a fake. The real implementation lives in
/// <c>RealMxGatewayClient</c> (Task 11).
/// </summary>
public interface IMxGatewayClient : IAsyncDisposable
{
/// <summary>Opens the gateway session and registers the client (Register → serverHandle held internally).</summary>
Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default);
/// <summary>Closes the session.</summary>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>AddItem + Advise; returns the gateway item handle (as a string subscription id).</summary>
Task<string> SubscribeAsync(string tagPath, CancellationToken ct = default);
/// <summary>UnAdvise + RemoveItem for a previously returned subscription id.</summary>
Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default);
/// <summary>Snapshot read of one or more tags (ReadBulk).</summary>
Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tagPaths, CancellationToken ct = default);
/// <summary>Write one or more tag/value pairs (WriteBulk with the configured WriteUserId).</summary>
Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default);
/// <summary>One Galaxy browse level (BrowseChildren). parentNodeId null → root.</summary>
Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default);
/// <summary>
/// Long-running event consumer. Invokes <paramref name="onUpdate"/> for each advised-tag
/// data change. Resumes from the last delivered worker sequence on reconnect. Completes
/// (or throws) when the stream ends — the adapter treats that as a disconnect.
/// </summary>
Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default);
}
/// <summary>Builds <see cref="IMxGatewayClient"/> instances.</summary>
public interface IMxGatewayClientFactory
{
IMxGatewayClient Create();
}
Step 2: Write MxGatewayGlobalOptions.cs
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
/// <summary>
/// Deployment-wide MxGateway defaults, bound from the "MxGateway" section of
/// appsettings.json. Per-endpoint behavior lives on MxGatewayEndpointConfig.
/// </summary>
public class MxGatewayGlobalOptions
{
/// <summary>Prefix used to derive a per-connection client registration name when the connection's ClientName is blank.</summary>
public string ClientNamePrefix { get; set; } = "scadabridge";
}
Step 3: Build the DCL project. Run: dotnet build src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/. Expected: PASS.
Step 4: Commit feat(dcl): MxGateway client seam interfaces + global options.
Task 6: Adapter — connect / disconnect / status / Disconnected + value mapping
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 13, Task 14
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs - Create:
tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs
Locate the existing OPC UA adapter test project (the dir holding
OpcUaDataConnectionTests/ fake clients) and place these alongside it; adjust the namespace/paths above if the actual project name differs.
Step 1: Write FakeMxGatewayClient — records calls; lets tests push MxValueUpdates into the captured onUpdate, and complete/fault the event loop on demand:
using System.Collections.Concurrent;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFactory
{
public MxGatewayConnectionOptions? ConnectedWith;
public readonly List<string> Subscribed = new();
public readonly TaskCompletionSource EventLoopGate = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Action<MxValueUpdate>? OnUpdate;
public Func<IReadOnlyList<string>, IReadOnlyList<MxReadOutcome>>? ReadHandler;
public Func<IReadOnlyList<(string, object?)>, IReadOnlyList<MxWriteOutcome>>? WriteHandler;
public Func<string?, (IReadOnlyList<MxBrowseChild>, bool)>? BrowseHandler;
private int _nextHandle;
public IMxGatewayClient Create() => this;
public Task ConnectAsync(MxGatewayConnectionOptions o, CancellationToken ct = default) { ConnectedWith = o; return Task.CompletedTask; }
public Task DisconnectAsync(CancellationToken ct = default) => Task.CompletedTask;
public Task<string> SubscribeAsync(string tag, CancellationToken ct = default) { Subscribed.Add(tag); return Task.FromResult((++_nextHandle).ToString()); }
public Task UnsubscribeAsync(string id, CancellationToken ct = default) { Subscribed.Remove(id); return Task.CompletedTask; }
public Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tags, CancellationToken ct = default) => Task.FromResult(ReadHandler!(tags));
public Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string, object?)> w, CancellationToken ct = default) => Task.FromResult(WriteHandler!(w));
public Task<(IReadOnlyList<MxBrowseChild>, bool)> BrowseChildrenAsync(string? p, CancellationToken ct = default) => Task.FromResult(BrowseHandler!(p));
public async Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default)
{
OnUpdate = onUpdate;
using var reg = ct.Register(() => EventLoopGate.TrySetResult());
await EventLoopGate.Task; // test completes this to end the loop…
ct.ThrowIfCancellationRequested(); // …or faults it to simulate a stream break
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public void FaultEventLoop() => EventLoopGate.TrySetException(new Exception("stream broke"));
}
Step 2: Write failing tests
[Fact]
public async Task ConnectAsync_resolves_options_and_sets_status_connected() { /* connect with a flat dict; assert fake.ConnectedWith.Endpoint + Status == Connected */ }
[Fact]
public async Task Disconnected_fires_exactly_once_when_event_loop_faults() { /* hook event; FaultEventLoop(); assert raised once */ }
Step 3: Implement the adapter core. Class declaration + connect + the value-mapping helper:
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
{
private readonly IMxGatewayClientFactory _clientFactory;
private readonly ILogger<MxGatewayDataConnection> _logger;
private IMxGatewayClient? _client;
private ConnectionHealth _status = ConnectionHealth.Disconnected;
private CancellationTokenSource? _eventLoopCts;
// subscriptionId → (tagPath, callback) so the event loop can route updates by tag.
private readonly ConcurrentDictionary<string, (string TagPath, SubscriptionCallback Callback)> _subs = new();
private readonly ConcurrentDictionary<string, string> _tagToSub = new();
private int _disconnectFired; // 0 = not fired, 1 = fired — Interlocked guard, mirrors OpcUaDataConnection.
public MxGatewayDataConnection(IMxGatewayClientFactory clientFactory, ILogger<MxGatewayDataConnection> logger)
{ _clientFactory = clientFactory; _logger = logger; }
public ConnectionHealth Status => _status;
public event Action? Disconnected;
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken ct = default)
{
var cfg = MxGatewayEndpointConfigSerializer.FromFlatDict(connectionDetails);
Interlocked.Exchange(ref _disconnectFired, 0); // reset guard on (re)connect, like OPC UA
_client = _clientFactory.Create();
await _client.ConnectAsync(new MxGatewayConnectionOptions(
cfg.Endpoint, cfg.ApiKey,
string.IsNullOrWhiteSpace(cfg.ClientName) ? "scadabridge" : cfg.ClientName,
cfg.WriteUserId, cfg.UseTls,
string.IsNullOrWhiteSpace(cfg.CaFile) ? null : cfg.CaFile,
string.IsNullOrWhiteSpace(cfg.ServerName) ? null : cfg.ServerName,
cfg.ReadTimeoutMs), ct);
_status = ConnectionHealth.Connected;
// Background event loop: route each value change to the matching subscription callback.
_eventLoopCts = new CancellationTokenSource();
_ = Task.Run(() => RunEventLoopAsync(_eventLoopCts.Token));
}
private async Task RunEventLoopAsync(CancellationToken ct)
{
try
{
await _client!.RunEventLoopAsync(update =>
{
if (_tagToSub.TryGetValue(update.TagPath, out var subId) && _subs.TryGetValue(subId, out var s))
s.Callback(update.TagPath, new TagValue(update.Value, update.Quality, update.Timestamp));
}, ct);
}
catch (OperationCanceledException) { /* normal shutdown */ }
catch (Exception ex)
{
_logger.LogWarning(ex, "MxGateway event stream faulted; signalling disconnect");
RaiseDisconnected();
}
}
private void RaiseDisconnected()
{
if (Interlocked.Exchange(ref _disconnectFired, 1) == 0)
{
_status = ConnectionHealth.Disconnected;
Disconnected?.Invoke();
}
}
public async Task DisconnectAsync(CancellationToken ct = default)
{
_eventLoopCts?.Cancel();
if (_client is not null) await _client.DisconnectAsync(ct);
_status = ConnectionHealth.Disconnected;
}
public async ValueTask DisposeAsync()
{
_eventLoopCts?.Cancel();
if (_client is not null) await _client.DisposeAsync();
}
// SubscribeAsync / UnsubscribeAsync — Task 7
// ReadAsync / ReadBatchAsync / WriteAsync / WriteBatchAsync — Task 8
// WriteBatchAndWaitAsync — Task 9
// BrowseChildrenAsync — Task 10
// (Throw NotImplementedException stubs for now so the file compiles.)
}
Add throw new NotImplementedException() stubs for the not-yet-implemented interface members so the project builds.
Step 4: Run tests, verify PASS. Run: dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ --filter MxGatewayDataConnectionTests.
Step 5: Commit feat(dcl): MxGatewayDataConnection connect/disconnect/Disconnected + value mapping.
Task 7: Adapter — subscribe / unsubscribe + event routing
Classification: high-risk Estimated implement time: ~4 min Parallelizable with: Task 13, Task 14 Depends on: Task 6
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs
Step 1: Test: after SubscribeAsync("Area.Pump.Speed", cb), pushing an MxValueUpdate for that tag through the fake's OnUpdate invokes cb with the mapped TagValue; UnsubscribeAsync stops routing.
Step 2: Run, verify FAIL (NotImplementedException).
Step 3: Implement:
public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken ct = default)
{
var subId = await _client!.SubscribeAsync(tagPath, ct);
_subs[subId] = (tagPath, callback);
_tagToSub[tagPath] = subId;
return subId;
}
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default)
{
if (_subs.TryRemove(subscriptionId, out var s)) _tagToSub.TryRemove(s.TagPath, out _);
await _client!.UnsubscribeAsync(subscriptionId, ct);
}
Step 4: Run tests, verify PASS. Step 5: Commit feat(dcl): MxGateway subscribe/unsubscribe + event routing.
Task 8: Adapter — read / write + error classification
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 13, Task 14 Depends on: Task 6
Files:
- Modify:
MxGatewayDataConnection.cs - Test:
MxGatewayDataConnectionTests.cs
Step 1: Tests: ReadAsync maps a successful MxReadOutcome to ReadResult(true, TagValue, null) and a failed one to ReadResult(false, null, error); ReadBatchAsync returns a dict keyed by tag; WriteAsync maps MxWriteOutcome success/failure to WriteResult; WriteBatchAsync returns a per-tag dict.
Step 2: Run, verify FAIL.
Step 3: Implement (single reads/writes delegate to the batch path):
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken ct = default)
{
var r = (await _client!.ReadAsync(new[] { tagPath }, ct)).Single();
return r.Success
? new ReadResult(true, new TagValue(r.Value, r.Quality, r.Timestamp), null)
: new ReadResult(false, null, r.Error);
}
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken ct = default)
{
var list = tagPaths.ToList();
var results = await _client!.ReadAsync(list, ct);
return results.ToDictionary(r => r.TagPath, r => r.Success
? new ReadResult(true, new TagValue(r.Value, r.Quality, r.Timestamp), null)
: new ReadResult(false, null, r.Error));
}
public async Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken ct = default)
{
var w = (await _client!.WriteAsync(new[] { (tagPath, value) }, ct)).Single();
return new WriteResult(w.Success, w.Error);
}
public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken ct = default)
{
var results = await _client!.WriteAsync(values.Select(kv => (kv.Key, kv.Value)).ToList(), ct);
return results.ToDictionary(w => w.TagPath, w => new WriteResult(w.Success, w.Error));
}
Error-classification note for the implementer: per-tag failures (Success == false) are returned to the caller as shown — they must NOT raise Disconnected. Transport/session faults surface as exceptions from the seam (the real impl in Task 11 throws MxGatewaySessionException/gRPC errors), which the DataConnectionActor already catches and which the event loop turns into a Disconnected. Auth failures are handled at connect time (Task 11).
Step 4: Run tests, verify PASS. Step 5: Commit feat(dcl): MxGateway read/write batch + error classification.
Task 9: Adapter — WriteBatchAndWaitAsync
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 13, Task 14 Depends on: Task 8
Files:
- Modify:
MxGatewayDataConnection.cs - Test:
MxGatewayDataConnectionTests.cs
Step 1: Tests: writes values+flag, then polls responsePath; returns true when the response value appears before timeout, false on timeout. Drive via the fake's ReadHandler (return the expected value after N polls / never).
Step 2: Run, verify FAIL.
Step 3: Implement generically (write the batch, write the flag, poll the response path until match or timeout):
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object?> values, string flagPath, object? flagValue,
string responsePath, object? responseValue, TimeSpan timeout, CancellationToken ct = default)
{
await WriteBatchAsync(values, ct);
await WriteAsync(flagPath, flagValue, ct);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
try
{
while (!timeoutCts.IsCancellationRequested)
{
var r = await ReadAsync(responsePath, timeoutCts.Token);
if (r.Success && Equals(r.Value?.ToString(), responseValue?.ToString())) return true;
await Task.Delay(TimeSpan.FromMilliseconds(200), timeoutCts.Token);
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { /* timeout */ }
return false;
}
(Value comparison uses string projection to tolerate numeric type differences across the gRPC boundary — match the OPC UA adapter's comparison approach if it differs; check OpcUaDataConnection.WriteBatchAndWaitAsync.)
Step 4: Run tests, verify PASS. Step 5: Commit feat(dcl): MxGateway WriteBatchAndWait.
Task 10: Adapter — Galaxy browse (IBrowsableDataConnection)
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 13, Task 14 Depends on: Task 6
Files:
- Modify:
MxGatewayDataConnection.cs - Test:
MxGatewayDataConnectionTests.cs
Step 1: Tests: BrowseChildrenAsync(null) maps the fake's children to BrowseChildrenResult (object→BrowseNodeClass.Object, attribute→Variable, HasChildren preserved, Truncated flag passed through); when the seam reports not-connected, the adapter throws ConnectionNotConnectedException.
Step 2: Run, verify FAIL.
Step 3: Implement (the seam already returns neutral MxBrowseChilds; the Galaxy→node mapping itself lives in the real impl, Task 11):
public async Task<BrowseChildrenResult> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default)
{
if (_status != ConnectionHealth.Connected)
throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");
var (children, truncated) = await _client!.BrowseChildrenAsync(parentNodeId, ct);
var nodes = children
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
.ToList();
return new BrowseChildrenResult(nodes, truncated);
}
Step 4: Run tests, verify PASS. Step 5: Commit feat(dcl): MxGateway Galaxy browse via IBrowsableDataConnection.
Task 11: RealMxGatewayClient — seam implementation over the NuGet client
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 13, Task 14 Depends on: Task 1, Task 5
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs
This is the only file that touches the generated protobuf/gRPC types. It is integration-shaped (no unit test — exercised in Task 19's smoke against a live/staged gateway). Implement IMxGatewayClient + IMxGatewayClientFactory (RealMxGatewayClientFactory).
Implementation notes (use the verbatim client API from the design doc / key-facts section):
ConnectAsync: buildMxGatewayClientOptions { Endpoint = new Uri(o.Endpoint), ApiKey = o.ApiKey, UseTls = o.UseTls, CaCertificatePath = o.CaFile, ServerNameOverride = o.ServerName };_client = MxGatewayClient.Create(opts);_galaxy = GalaxyRepositoryClient.Create(opts);_session = await _client.OpenSessionAsync(ct);_serverHandle = await _session.RegisterAsync(o.ClientName, ct); storeo.WriteUserIdando.ReadTimeoutMs. CatchMxGatewayAuthenticationException/MxGatewayAuthorizationExceptionand rethrow (the actor logs + retries on the reconnect cadence — auth failure is treated like a failed connect).SubscribeAsync(tag):var h = await _session.AddItemAsync(_serverHandle, tag, ct); await _session.AdviseAsync(_serverHandle, h, ct);tracktag↔h; returnh.ToString().UnsubscribeAsync(id): parse handle;await _session.UnAdviseAsync(_serverHandle, h, ct); await _session.RemoveItemAsync(_serverHandle, h, ct);.ReadAsync(tags):var results = await _session.ReadBulkAsync(_serverHandle, tags, TimeSpan.FromMilliseconds(_readTimeoutMs), ct);map eachBulkReadResult→MxReadOutcome(r.TagAddress, r.WasSuccessful, r.Value.ToClrValue(), MapQuality(r.Quality, r.Statuses), r.SourceTimestamp.ToDateTimeOffset(), r.WasSuccessful ? null : r.ErrorMessage).WriteAsync(writes): buildWriteBulkEntry { ItemHandle = handleForTag, Value = value.ToMxValue(), UserId = _writeUserId }(resolve item handle; AddItem on demand if the tag isn't already advised — keep a tag→handle cache).await _session.WriteBulkAsync(...); mapBulkWriteResult→MxWriteOutcome(tag, r.WasSuccessful, r.WasSuccessful ? null : r.ErrorMessage). Value conversion uses theToMxValue()overloads — pick by runtime type (bool/int/long/float/double/string/DateTime).BrowseChildrenAsync(parentNodeId): buildBrowseChildrenRequest { IncludeAttributes = true }; ifparentNodeIdis non-null setParentContainedPath = parentNodeId(the NodeId we emit for objects is the contained path);var reply = await _galaxy.BrowseChildrenRawAsync(req, ct);then map: eachGalaxyObject→MxBrowseChild(NodeId: obj.ContainedName-or-derived-path, DisplayName: obj.TagName, NodeClass.Object, HasChildren: reply.ChildHasChildren[i]); eachGalaxyAttribute→MxBrowseChild(NodeId: attr.FullTagReference, DisplayName: attr.AttributeName, NodeClass.Variable, HasChildren: false). Truncated =!string.IsNullOrEmpty(reply.NextPageToken). Map any gRPCRpcExceptionwithStatusCode.UnavailabletoConnectionNotConnectedException.RunEventLoopAsync(onUpdate, ct):await foreach (var ev in _session.StreamEventsAsync(_lastSeq, ct))— for eachevwhereev.Family == MxEventFamily.OnDataChange, resolve the tag fromev.ItemHandle,onUpdate(new MxValueUpdate(tag, ev.Value.ToClrValue(), MapQuality(ev.Quality, ev.Statuses), ev.SourceTimestamp.ToDateTimeOffset())), then_lastSeq = ev.WorkerSequence. Let the loop throw on stream break — the adapter turns that intoDisconnected.MapQuality(int quality, IReadOnlyList<MxStatusProxy> statuses):if (statuses.Any(s => !s.IsSuccess())) return QualityCode.Bad;return quality >= 192 ? QualityCode.Good : (quality >= 64 ? QualityCode.Uncertain : QualityCode.Bad);(192 = OPC Good, 64 = Uncertain band).DisposeAsync/DisconnectAsync: dispose session + both clients.
Step 1: Write the file. Step 2: Build the DCL project: dotnet build src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ — expected PASS (this is where the generated-type field names are verified against the package; fix any casing mismatches against IntelliSense/the generated .cs). Step 3: Commit feat(dcl): RealMxGatewayClient over ZB.MOM.WW.MxGateway.Client.
Task 12: Factory registration + options binding + config flatten branch
Classification: standard
Estimated implement time: ~5 min
Parallelizable with: Task 13, Task 14 — NO (this task and Task 13 both edit DeploymentManagerActor.cs; run them sequentially)
Depends on: Task 3, Task 5, Task 11
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:759 - Test:
tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs(and the SiteRuntime test project for the flatten branch)
Step 1: Test — factory.Create("MxGateway", new Dictionary<string,string>()) returns a MxGatewayDataConnection; FlattenConnectionConfig("MxGateway", json) produces the flat dict from MxGatewayEndpointConfigSerializer. Run, verify FAIL.
Step 2: Register the adapter in DataConnectionFactory (constructor, after the OPC UA registration). The factory currently takes ILoggerFactory + IOptions<OpcUaGlobalOptions>; add an optional IOptions<MxGatewayGlobalOptions> param (default Options.Create(new MxGatewayGlobalOptions()) in the convenience ctor, mirroring the existing OPC UA pattern):
RegisterAdapter("MxGateway", details => new MxGatewayDataConnection(
new RealMxGatewayClientFactory(_loggerFactory),
_loggerFactory.CreateLogger<MxGatewayDataConnection>()));
Step 3: Bind options in ServiceCollectionExtensions.AddDataConnectionLayer (after the OpcUaGlobalOptions bind):
services.AddOptions<MxGatewayGlobalOptions>()
.BindConfiguration("MxGateway");
Step 4: Add the flatten branch in DeploymentManagerActor.FlattenConnectionConfig (before the generic fallback):
if (string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
{
var config = Commons.Serialization.MxGatewayEndpointConfigSerializer.Deserialize(json);
return Commons.Serialization.MxGatewayEndpointConfigSerializer.ToFlatDict(config);
}
Step 5: Run tests, verify PASS; build the solution. Step 6: Commit feat(dcl): register MxGateway protocol in factory + config flatten + options binding.
Task 13: Rename browse message types to protocol-agnostic names
Classification: standard
Estimated implement time: ~5 min
Parallelizable with: Tasks 2–11 (adapter track) — NOT Task 12 (shared file DeploymentManagerActor.cs)
Mechanical rename. No new test — the existing browse tests are the regression guard. Rename map (old → new):
BrowseOpcUaNodeCommand→BrowseNodeCommandBrowseOpcUaNodeResult→BrowseNodeResultCommunicationService.BrowseOpcUaNodeAsync→BrowseNodeAsync
Files (every reference — from the inventory):
src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs(definitions; update the OPC-UA-specific XML doc comments to be protocol-neutral)src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs:360-371(method + return type)src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs:155,158src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:152,157(browse routing — NOT the FlattenConnectionConfig method)src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs:49,118-142src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:237,310,435,985-1046src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs,OpcUaBrowseService.cs(type refs only; the service rename is Task 14)src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs:11(doc cross-ref)- Any test files referencing
BrowseOpcUaNode*(grep thetests/tree).
Step 1: grep -rn "BrowseOpcUaNode" src tests --include="*.cs" --include="*.razor" to get the live list. Step 2: Rename across all hits (keep BrowseFailure/BrowseFailureKind/BrowseNode/BrowseChildrenResult unchanged — already generic). Step 3: dotnet build ZB.MOM.WW.ScadaBridge.slnx — expected PASS. Step 4: Run existing browse tests: dotnet test --filter "FullyQualifiedName~Browse" — expected PASS. Step 5: Commit refactor(browse): rename BrowseOpcUaNode* to protocol-agnostic BrowseNode*.
Task 14: Rename browse service + dialog to protocol-agnostic names
Classification: standard Estimated implement time: ~5 min Parallelizable with: Tasks 2–11 Depends on: Task 13
Mechanical rename within Central UI. Rename map:
IOpcUaBrowseService→IBrowseService;OpcUaBrowseService→BrowseService(file renames too)OpcUaBrowserDialog.razor→NodeBrowserDialog.razor(component + file rename)- Modal title
"Browse OPC UA — @ConnectionName"→"Browse — @ConnectionName"(protocol-neutral)
Files:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs→IBrowseService.cssrc/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs→BrowseService.cs(updateBrowseOpcUaNodeAsynccall toBrowseNodeAsyncfrom Task 13; update doc comments)src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor→NodeBrowserDialog.razorsrc/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs:56(DI registration)src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs:13,Services/IBindingTester.cs:19(type refs)src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor:49-51src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor:370,378,410(<OpcUaBrowserDialog>tag +_browserReftype +OpcUaBrowserDialog?field)src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TestBindingsDialog.razor:146- Any CentralUI tests referencing these symbols (grep
tests/).
Step 1: grep -rn "OpcUaBrowseService\|IOpcUaBrowseService\|OpcUaBrowserDialog" src tests for the live list. Step 2: Rename (use git mv for the file renames). Step 3: dotnet build ZB.MOM.WW.ScadaBridge.slnx — PASS. Step 4: Run CentralUI tests: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ — PASS. Step 5: Commit refactor(browse): rename OPC-UA browse service + dialog to protocol-agnostic.
Task 15: MxGatewayEndpointEditor.razor
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 13 Depends on: Task 3 Sub-skill: @frontend-design
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Forms/MxGatewayEndpointEditor.razor
Mirror OpcUaEndpointEditor.razor (read it first for the parameter/binding/validation-display conventions, and follow the form-layout memory: vertical stacking, read-only fields first, buttons at bottom). Two-way bind a MxGatewayEndpointConfig parameter; render inputs for Endpoint, ApiKey (type=password), ClientName, WriteUserId, UseTls (checkbox toggling CaFile/ServerName), ReadTimeoutMs. Show validation errors from MxGatewayEndpointConfigValidator. Parameters: [Parameter] public string Title, [Parameter] public MxGatewayEndpointConfig Config, [Parameter] public EventCallback<MxGatewayEndpointConfig> ConfigChanged, [Parameter] public IReadOnlyList<string> Errors.
Step 1: Build the editor. Step 2: dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ — PASS. Step 3: Commit feat(centralui): MxGatewayEndpointEditor component.
Task 16: Protocol selector in DataConnectionForm
Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on 15) Depends on: Task 3, Task 15 Sub-skill: @frontend-design
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor
Currently hardcodes Protocol = "OpcUa" with <OpcUaEndpointEditor>. Add:
- A protocol
<select>(OpcUa | MxGateway) bound to a_protocolfield, defaulting from_editingConnection?.Protocol ?? "OpcUa". Disable changing the protocol when editing an existing connection (changing protocol on a saved connection invalidates its config — keep it create-time only; for edit, show it read-only). - Conditional editor:
@if (_protocol == "OpcUa") { <OpcUaEndpointEditor …/> } else { <MxGatewayEndpointEditor …/> }for primary and (when shown) backup. - Separate
_primaryMxConfig/_backupMxConfigfields of typeMxGatewayEndpointConfig. - On load: pick the serializer by
_editingConnection.Protocol. On save: serialize with the matching serializer and set_editingConnection.Protocol = _protocol(replace the two hardcoded"OpcUa"literals at lines ~205/213). - Validate with the matching validator before save.
Step 1: Implement the branching. Step 2: dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI/ — PASS. Step 3: If a bUnit test project covers DataConnectionForm, add/adjust a test that selecting MxGateway renders the MxGateway editor and saves Protocol="MxGateway"; run it. Step 4: Commit feat(centralui): protocol selector + MxGateway editor in DataConnectionForm.
Task 17: Verify MxGateway tag picker on Configure Instance
Classification: small Estimated implement time: ~3 min Parallelizable with: none Depends on: Task 14
Files:
- Modify (if needed):
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
The browse path is now protocol-agnostic, so NodeBrowserDialog works for any connection whose site-side adapter implements IBrowsableDataConnection. Confirm InstanceConfigure opens the dialog for a connection regardless of protocol (no Protocol == "OpcUa" gate on the browse button). If such a gate exists, remove it / generalize it. The dialog's manual-node-id field already provides the fallback for non-browsable connections.
Step 1: grep -n "OpcUa\|Browse" src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor — check for a protocol gate on the browse affordance. Step 2: Remove/generalize any gate; build. Step 3: Commit feat(centralui): enable tag picker for MxGateway connections.
Task 18: Documentation
Classification: trivial Estimated implement time: ~4 min Parallelizable with: Task 17 Depends on: Task 12
Files:
- Modify:
docs/requirements/Component-DataConnectionLayer.md - Modify:
README.md(only if it enumerates protocols)
Add MxGateway under Supported Protocols (gRPC to the MxAccess Gateway; session-based; Galaxy browse via GalaxyRepositoryClient); add an MxGateway Settings config table mirroring the OPC UA one (the Task 2 field table); update the Browsing the address space section to note IBrowsableDataConnection now backs both OPC UA and MxGateway via the protocol-agnostic BrowseNodeCommand/BrowseService. Commit docs(dcl): document MxGateway protocol.
Task 19: Full build, test suite, and deploy smoke
Classification: high-risk Estimated implement time: ~5 min (+ build/deploy wall time) Parallelizable with: none Depends on: all prior tasks
Files:
- Modify (if needed):
docker/deploy.sh(pass NuGet feed credentials into the image build, e.g. a--build-argor a mountednuget.configwith creds, sodotnet restoreinside the container resolves the Gitea packages)
Step 1: dotnet build ZB.MOM.WW.ScadaBridge.slnx — expected: PASS, no warnings (project treats warnings as errors). Step 2: dotnet test ZB.MOM.WW.ScadaBridge.slnx — expected: all green (new adapter/serializer/validator tests + unchanged browse regression tests). Step 3: Wire the NuGet credential into docker/deploy.sh if the image build can't restore the Gitea feed; rebuild: bash docker/deploy.sh. Step 4 (optional live smoke): create an MxGateway connection via the CLI/UI pointed at a staged gateway, deploy an instance with a bound tag, confirm values flow and the tag picker browses the Galaxy. Step 5: Commit any deploy.sh change: build(docker): supply Gitea NuGet credentials for image restore.
Parallelization summary
- Adapter track (Tasks 2→12) and Rename track (Tasks 13→14) run concurrently — disjoint files except
DeploymentManagerActor.cs(Task 12's flatten method vs. Task 13's browse routing). Run Task 12 after Task 13 to avoid the collision. - UI track (Tasks 15→16, 17) depends on the serializer (Task 3) and the rename (Task 14).
- Within the adapter track: Task 6 gates 7/8/10; Task 8 gates 9; Task 11 + 3 gate 12.
- Docs (18) and final integration (19) come last.