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.
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
# 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 <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**
|
||||
|
||||
```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<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**
|
||||
|
||||
```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 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` → `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 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.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.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-28-mxgateway-data-connection.md",
|
||||
"tasks": [
|
||||
{"id": 6, "planTask": 1, "subject": "Task 1: Packaging foundation (Gitea feed + package refs)", "status": "pending"},
|
||||
{"id": 7, "planTask": 2, "subject": "Task 2: MxGatewayEndpointConfig type", "status": "pending"},
|
||||
{"id": 8, "planTask": 3, "subject": "Task 3: MxGatewayEndpointConfigSerializer + tests", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "planTask": 4, "subject": "Task 4: MxGatewayEndpointConfigValidator + tests", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 10, "planTask": 5, "subject": "Task 5: Client seam interfaces + MxGatewayGlobalOptions", "status": "pending"},
|
||||
{"id": 11, "planTask": 6, "subject": "Task 6: Adapter connect/disconnect/Disconnected + value mapping", "status": "pending", "blockedBy": [7, 10]},
|
||||
{"id": 12, "planTask": 7, "subject": "Task 7: Adapter subscribe/unsubscribe + event routing", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 13, "planTask": 8, "subject": "Task 8: Adapter read/write batch + error classification", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 14, "planTask": 9, "subject": "Task 9: Adapter WriteBatchAndWaitAsync", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "planTask": 10, "subject": "Task 10: Adapter Galaxy browse (IBrowsableDataConnection)", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 16, "planTask": 11, "subject": "Task 11: RealMxGatewayClient seam implementation", "status": "pending", "blockedBy": [6, 10]},
|
||||
{"id": 17, "planTask": 12, "subject": "Task 12: Factory registration + options binding + flatten branch", "status": "pending", "blockedBy": [8, 16, 18]},
|
||||
{"id": 18, "planTask": 13, "subject": "Task 13: Rename browse message types to protocol-agnostic", "status": "pending"},
|
||||
{"id": 19, "planTask": 14, "subject": "Task 14: Rename browse service + dialog to protocol-agnostic", "status": "pending", "blockedBy": [18]},
|
||||
{"id": 20, "planTask": 15, "subject": "Task 15: MxGatewayEndpointEditor.razor", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 21, "planTask": 16, "subject": "Task 16: Protocol selector in DataConnectionForm", "status": "pending", "blockedBy": [8, 20, 19]},
|
||||
{"id": 22, "planTask": 17, "subject": "Task 17: Verify MxGateway tag picker on Configure Instance", "status": "pending", "blockedBy": [19]},
|
||||
{"id": 23, "planTask": 18, "subject": "Task 18: Documentation", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 24, "planTask": 19, "subject": "Task 19: Full build, test suite, deploy smoke", "status": "pending", "blockedBy": [17, 21, 22, 23]}
|
||||
],
|
||||
"lastUpdated": "2026-05-28"
|
||||
}
|
||||
Reference in New Issue
Block a user