Files
ScadaBridge/docs/plans/2026-05-28-mxgateway-data-connection.md
T
Joseph Doherty 2044023bdd docs(dcl): implementation plan for MxGateway data connection
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.
2026-05-29 07:39:44 -04:00

957 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 via `RegisterAdapter("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. `FlattenConnectionConfig` currently branches on `"OpcUa"` and falls back to a generic flat-dict parse for unknown protocols — add an `"MxGateway"` arm.
- **`IDataConnection` contract:** `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 }`. Throw `ConnectionNotConnectedException` when 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 `_disconnectFired` `Interlocked.Exchange` once-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()`.
- **`MxEvent`** fields: `family` (`MxEventFamily`), `item_handle`, `value` (`MxValue`), `quality` (int, OPC-style; ≥192 = good), `source_timestamp` (`google.protobuf.Timestamp`), `worker_sequence` (uint64), `body` oneof 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`. **`BrowseChildrenRequest`** parent oneof: `parent_gobject_id` | `parent_tag_name` | `parent_contained_path`; plus `page_size`, `include_attributes`.
- **Exceptions:** `MxGatewaySessionException`, `MxGatewayAuthenticationException`, `MxGatewayAuthorizationException`, `MxGatewayWorkerException`, `MxGatewayCommandException`, `MxAccessException` — all derive from `MxGatewayException`.
- **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
<?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):
```xml
<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):
```xml
<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**
```bash
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):
```csharp
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 &lt;key&gt;</c>.</summary>
public string ApiKey { get; set; } = "";
/// <summary>MXAccess client registration name. Blank → derive "scadabridge-&lt;connName&gt;" 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**
```bash
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**
```csharp
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**
```csharp
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&lt;string,string&gt;</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**
```bash
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`**
```csharp
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`**
```csharp
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 `MxValueUpdate`s into the captured `onUpdate`, and complete/fault the event loop on demand:
```csharp
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**
```csharp
[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:
```csharp
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:
```csharp
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):
```csharp
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):
```csharp
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 `MxBrowseChild`s; the Galaxy→node mapping itself lives in the real impl, Task 11):
```csharp
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`: build `MxGatewayClientOptions { 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)`; store `o.WriteUserId` and `o.ReadTimeoutMs`. Catch `MxGatewayAuthenticationException`/`MxGatewayAuthorizationException` and 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);` track `tag↔h`; return `h.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 each `BulkReadResult``MxReadOutcome(r.TagAddress, r.WasSuccessful, r.Value.ToClrValue(), MapQuality(r.Quality, r.Statuses), r.SourceTimestamp.ToDateTimeOffset(), r.WasSuccessful ? null : r.ErrorMessage)`.
- `WriteAsync(writes)`: build `WriteBulkEntry { 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(...)`; map `BulkWriteResult``MxWriteOutcome(tag, r.WasSuccessful, r.WasSuccessful ? null : r.ErrorMessage)`. **Value conversion** uses the `ToMxValue()` overloads — pick by runtime type (bool/int/long/float/double/string/DateTime).
- `BrowseChildrenAsync(parentNodeId)`: build `BrowseChildrenRequest { IncludeAttributes = true }`; if `parentNodeId` is non-null set `ParentContainedPath = parentNodeId` (the NodeId we emit for objects is the contained path); `var reply = await _galaxy.BrowseChildrenRawAsync(req, ct);` then map: each `GalaxyObject``MxBrowseChild(NodeId: obj.ContainedName-or-derived-path, DisplayName: obj.TagName, NodeClass.Object, HasChildren: reply.ChildHasChildren[i])`; each `GalaxyAttribute``MxBrowseChild(NodeId: attr.FullTagReference, DisplayName: attr.AttributeName, NodeClass.Variable, HasChildren: false)`. Truncated = `!string.IsNullOrEmpty(reply.NextPageToken)`. Map any gRPC `RpcException` with `StatusCode.Unavailable` to `ConnectionNotConnectedException`.
- `RunEventLoopAsync(onUpdate, ct)`: `await foreach (var ev in _session.StreamEventsAsync(_lastSeq, ct))` — for each `ev` where `ev.Family == MxEventFamily.OnDataChange`, resolve the tag from `ev.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 into `Disconnected`.
- `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):
```csharp
RegisterAdapter("MxGateway", details => new MxGatewayDataConnection(
new RealMxGatewayClientFactory(_loggerFactory),
_loggerFactory.CreateLogger<MxGatewayDataConnection>()));
```
**Step 3: Bind options** in `ServiceCollectionExtensions.AddDataConnectionLayer` (after the `OpcUaGlobalOptions` bind):
```csharp
services.AddOptions<MxGatewayGlobalOptions>()
.BindConfiguration("MxGateway");
```
**Step 4: Add the flatten branch** in `DeploymentManagerActor.FlattenConnectionConfig` (before the generic fallback):
```csharp
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 211 (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``BrowseNodeCommand`
- `BrowseOpcUaNodeResult``BrowseNodeResult`
- `CommunicationService.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,158`
- `src/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-142`
- `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:237,310,435,985-1046`
- `src/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 the `tests/` 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 211
**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.cs`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs``BrowseService.cs` (update `BrowseOpcUaNodeAsync` call to `BrowseNodeAsync` from Task 13; update doc comments)
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor``NodeBrowserDialog.razor`
- `src/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-51`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor:370,378,410` (`<OpcUaBrowserDialog>` tag + `_browserRef` type + `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 `_protocol` field, 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`/`_backupMxConfig` fields of type `MxGatewayEndpointConfig`.
- 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-arg` or a mounted `nuget.config` with creds, so `dotnet restore` inside 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.