Merge feat/mxgateway-data-connection: MxGateway data connection (2nd protocol)

Adds MxGateway as a second DCL protocol alongside OPC UA: IDataConnection +
IBrowsableDataConnection adapter over the ZB.MOM.WW.MxGateway.Client package,
typed config + serializer/validator, factory registration, protocol-agnostic
browse plumbing, Central UI protocol selector + editor + tag picker, and docker
NuGet feed wiring. Full design + 19-task implementation plan in docs/plans.
This commit is contained in:
Joseph Doherty
2026-05-29 08:14:37 -04:00
46 changed files with 2820 additions and 118 deletions
+2
View File
@@ -73,6 +73,8 @@
to mark tests as Skipped (not silently Passed) when MSSQL is unreachable. to mark tests as Skipped (not silently Passed) when MSSQL is unreachable.
--> -->
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" /> <PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1 -2
View File
@@ -21,7 +21,6 @@ This document serves as the master index for the SCADA system design. The system
### Scale ### Scale
- ~10 site clusters, each with 50500 machines, 2575 live tags per machine.
- Central cluster: 2-node active/standby behind a load balancer. - Central cluster: 2-node active/standby behind a load balancer.
- Site clusters: 2-node active/standby, headless (no UI). - Site clusters: 2-node active/standby, headless (no UI).
@@ -46,7 +45,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, flattening, semantic validation, revision hashing, diff calculation, and folder organization (nested folders, drag-drop). | | 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, flattening, semantic validation, revision hashing, diff calculation, and folder organization (nested folders, drag-drop). |
| 2 | Deployment Manager | [docs/requirements/Component-DeploymentManager.md](docs/requirements/Component-DeploymentManager.md) | Central-side deployment pipeline with deployment ID/idempotency, per-instance operation lock, state transition matrix, all-or-nothing site apply, system-wide artifact deployment with per-site status. | | 2 | Deployment Manager | [docs/requirements/Component-DeploymentManager.md](docs/requirements/Component-DeploymentManager.md) | Central-side deployment pipeline with deployment ID/idempotency, per-instance operation lock, state transition matrix, all-or-nothing site apply, system-wide artifact deployment with per-site status. |
| 3 | Site Runtime | [docs/requirements/Component-SiteRuntime.md](docs/requirements/Component-SiteRuntime.md) | Site-side actor hierarchy with explicit supervision strategies, staggered startup, script trust model (constrained APIs), Tell/Ask conventions, concurrency serialization, and site-wide Akka stream with per-subscriber backpressure. | | 3 | Site Runtime | [docs/requirements/Component-SiteRuntime.md](docs/requirements/Component-SiteRuntime.md) | Site-side actor hierarchy with explicit supervision strategies, staggered startup, script trust model (constrained APIs), Tell/Ask conventions, concurrency serialization, and site-wide Akka stream with per-subscriber backpressure. |
| 4 | Data Connection Layer | [docs/requirements/Component-DataConnectionLayer.md](docs/requirements/Component-DataConnectionLayer.md) | Common data connection interface (OPC UA, custom), Become/Stash connection actor model, auto-reconnect, immediate bad quality on disconnect, transparent re-subscribe, synchronous write failures, tag path resolution retry. | | 4 | Data Connection Layer | [docs/requirements/Component-DataConnectionLayer.md](docs/requirements/Component-DataConnectionLayer.md) | Common data connection interface (OPC UA, MxGateway, custom), Become/Stash connection actor model, auto-reconnect, immediate bad quality on disconnect, transparent re-subscribe, synchronous write failures, tag path resolution retry, protocol-agnostic address-space browse. |
| 5 | CentralSite Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 9 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. | | 5 | CentralSite Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 9 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. |
| 6 | Store-and-Forward Engine | [docs/requirements/Component-StoreAndForward.md](docs/requirements/Component-StoreAndForward.md) | Buffering (transient failures only), fixed-interval retry, parking, async best-effort replication, SQLite persistence at sites. | | 6 | Store-and-Forward Engine | [docs/requirements/Component-StoreAndForward.md](docs/requirements/Component-StoreAndForward.md) | Buffering (transient failures only), fixed-interval retry, parking, async best-effort replication, SQLite persistence at sites. |
| 7 | External System Gateway | [docs/requirements/Component-ExternalSystemGateway.md](docs/requirements/Component-ExternalSystemGateway.md) | HTTP/REST + JSON, API key/Basic Auth, per-system timeout, dual call modes (Call/CachedCall), transient/permanent error classification, dedicated blocking I/O dispatcher, ADO.NET connection pooling. | | 7 | External System Gateway | [docs/requirements/Component-ExternalSystemGateway.md](docs/requirements/Component-ExternalSystemGateway.md) | HTTP/REST + JSON, API key/Basic Auth, per-system timeout, dual call modes (Call/CachedCall), transient/permanent error classification, dedicated blocking I/O dispatcher, ADO.NET connection pooling. |
+11
View File
@@ -27,6 +27,17 @@ COPY src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.Managemen
# projects) for `dotnet restore` to resolve versions — without it restore fails NU1015. # projects) for `dotnet restore` to resolve versions — without it restore fails NU1015.
COPY Directory.Packages.props ./ COPY Directory.Packages.props ./
# nuget.config declares the Gitea feed (and package-source mapping) that serves the
# ZB.MOM.WW.MxGateway.* packages used by the Data Connection Layer.
COPY nuget.config ./
# Optional credentials for the private Gitea feed, supplied at build time via
# --build-arg (see docker/build.sh). Left blank for an anonymous feed. NuGet reads
# per-source credentials from the NuGetPackageSourceCredentials_<sourceName> env var.
ARG NUGET_GITEA_USER=
ARG NUGET_GITEA_PASS=
ENV NuGetPackageSourceCredentials_dohertj2-gitea="Username=${NUGET_GITEA_USER};Password=${NUGET_GITEA_PASS}"
# Restore NuGet packages via Host project (follows ProjectReferences to all dependencies) # Restore NuGet packages via Host project (follows ProjectReferences to all dependencies)
# This layer is cached until any .csproj changes — source-only changes skip restore entirely # This layer is cached until any .csproj changes — source-only changes skip restore entirely
RUN dotnet restore src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj RUN dotnet restore src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj
+10
View File
@@ -12,11 +12,21 @@ if ! docker network inspect scadabridge-net >/dev/null 2>&1; then
docker network create scadabridge-net docker network create scadabridge-net
fi fi
# Optional credentials for the private Gitea NuGet feed (ZB.MOM.WW.MxGateway.*).
# Read from the host environment so secrets are never committed. Leave unset for an
# anonymous feed. Export MXGW_NUGET_USER / MXGW_NUGET_PASS before running deploy.
NUGET_ARGS=()
if [ -n "${MXGW_NUGET_USER:-}" ]; then
NUGET_ARGS+=(--build-arg "NUGET_GITEA_USER=${MXGW_NUGET_USER}")
NUGET_ARGS+=(--build-arg "NUGET_GITEA_PASS=${MXGW_NUGET_PASS:-}")
fi
# Build from repo root (so COPY paths in Dockerfile resolve correctly) # Build from repo root (so COPY paths in Dockerfile resolve correctly)
echo "Building scadabridge:latest image..." echo "Building scadabridge:latest image..."
docker build \ docker build \
-t scadabridge:latest \ -t scadabridge:latest \
-f "$SCRIPT_DIR/Dockerfile" \ -f "$SCRIPT_DIR/Dockerfile" \
"${NUGET_ARGS[@]}" \
"$REPO_ROOT" "$REPO_ROOT"
echo "Build complete: scadabridge:latest" echo "Build complete: scadabridge:latest"
@@ -0,0 +1,199 @@
# MxGateway Data Connection — Design
**Date:** 2026-05-28
**Component:** Data Connection Layer (#4), with touches to Commons (#16), Central UI (#9), Host (#15)
**Status:** Approved — ready for implementation planning
## Summary
Add a second data-connection protocol, **MxGateway**, alongside the existing OPC UA
client. MxGateway connects to the MxAccess Gateway
(`https://gitea.dohertylan.com/dohertj2`, packages `ZB.MOM.WW.MxGateway.Client` +
`ZB.MOM.WW.MxGateway.Contracts`) over gRPC and exposes an AVEVA/Wonderware
MXAccess-backed Galaxy as a clean tag-value pipe, identical in role to the OPC UA
adapter.
The Data Connection Layer was built for exactly this: `DataConnectionFactory`
exposes `RegisterAdapter(protocolType, factory)` and every surrounding mechanism
(the `DataConnectionActor` Become/Stash state machine, primary/backup failover,
health reporting, re-subscribe-on-reconnect) is protocol-agnostic. The new
protocol is a single `IDataConnection` adapter plus one registration line — no
changes to the actor, the entity schema, or the failover machinery.
## Scope
**In scope (this slice):**
- Read / Subscribe / Write — MxGateway as a clean tag-value pipe.
- Galaxy hierarchy browse for the instance-config tag picker.
- Optional second endpoint for failover (reusing the existing primary/backup model).
**Out of scope (possible later slices):**
- Native MXAccess alarms (`QueryActiveAlarms` / `StreamAlarms` / `AcknowledgeAlarm`).
ScadaBridge evaluates its own alarms via Alarm Actors from tag values; native
alarms are a new concept.
- Secured writes (`WriteSecured`, operator + verifier userId). Plain writes carry a
configurable `WriteUserId` only.
## Decisions
| Decision | Choice |
|---|---|
| Approach | New `IDataConnection` adapter behind the existing factory extension point (not a shared base class, not a separate subsystem). |
| Protocol string | `"MxGateway"` (matches the NuGet package family). |
| Browse plumbing | **Generalized** to protocol-agnostic browse driven by `IBrowsableDataConnection`; OPC UA and MxGateway share one path. |
| Write user context | Optional `WriteUserId` config field, default `0`. No script API change. |
| Endpoint redundancy | Reuse existing primary/backup failover; backup = a second gateway endpoint. |
| ApiKey secret handling | Match whatever OPC UA `UserIdentityConfig` username/password does today. |
## Section 1 — Adapter & client lifecycle mapping
New project-internal `MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection`
in `ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/`, wrapping an injected
`IMxGatewayClientFactory` (mirrors the `IOpcUaClientFactory` seam so it is
unit-testable with a fake).
| `IDataConnection` | MxGateway client |
|---|---|
| `ConnectAsync(details)` | `MxGatewayClient.Create(Endpoint, ApiKey, TLS)``OpenSessionAsync``RegisterAsync(clientName)` (store `serverHandle`); start background `StreamEventsAsync` consumer loop |
| `SubscribeAsync(tagPath, cb)` | `AddItemAsync``AdviseAsync` (or `SubscribeBulkAsync`); map `itemHandle ↔ tagPath ↔ callback`; return subscriptionId |
| `UnsubscribeAsync(id)` | `UnAdviseAsync` + `RemoveItemAsync` |
| `ReadAsync` / `ReadBatchAsync` | `ReadBulkAsync` (uses cached advised value when present) |
| `WriteAsync` / `WriteBatchAsync` | `WriteBulkAsync` with `WriteUserId`; value via `ToMxValue()` |
| `WriteBatchAndWaitAsync` | generic compose: write values → write flag → poll `responsePath` (advised value or `ReadBulk`) until match/timeout |
| `Status` | `ConnectionHealth` tracked across session state |
| `Disconnected` | fired once (Interlocked guard) when `StreamEventsAsync` faults or the channel breaks |
**Value/quality mapping.** Each `OnDataChange` `MxEvent` carries `item_handle`,
`value` (`MxValue``ToClrValue()`), `quality` (OPC-style int), `source_timestamp`,
`statuses`, and `worker_sequence`. Dispatched to the matching tag's
`SubscriptionCallback` as `TagValue(ToClrValue(value), mapQuality(quality, statuses),
source_timestamp)`. Quality: `quality >= 192``Good`; bad-category status → `Bad`;
otherwise `Uncertain`. The loop tracks `worker_sequence` and resumes with
`afterWorkerSequence` on reconnect so no change is missed.
**Reconnection needs no new logic.** The existing `DataConnectionActor` catches
`Disconnected`, pushes bad quality to all subscribed tags, disposes the adapter, and
on retry calls `ConnectAsync` on a fresh adapter then re-subscribes all tags —
identical to OPC UA.
## Section 2 — Configuration, secrets & endpoint redundancy
New `MxGatewayEndpointConfig` in Commons (alongside `OpcUaEndpointConfig`) with a
matching `MxGatewayEndpointConfigSerializer` (flat-dict ⇄ JSON) and
`MxGatewayEndpointConfigValidator`. Stored exactly like OPC UA: per-connection JSON
in `DataConnection.PrimaryConfiguration` / `BackupConfiguration`. **Primary/backup
failover works for free** — backup = a second gateway endpoint, round-robin, no
auto-failback, driven by the existing `FailoverRetryCount` state machine. No entity
or migration changes.
| Key | Type | Default | Notes |
|---|---|---|---|
| `Endpoint` | string | `http://localhost:5000` | Gateway base URL |
| `ApiKey` | string | — | Sent as `authorization: Bearer <key>` |
| `ClientName` | string | `scadabridge-<connName>` | Registration name |
| `WriteUserId` | int | `0` | Applied to every write-back |
| `UseTls` / `CaFile` / `ServerName` | bool/string/string | `false` / — / — | TLS to a secured gateway |
| `ReadTimeoutMs` | int | `5000` | `ReadBulk` per-call timeout |
**Secrets.** `ApiKey` follows whatever OPC UA `UserIdentityConfig` username/password
does today (same at-rest treatment, same log/telemetry redaction). Match that pattern
exactly; if OPC UA stores credentials in plaintext, `ApiKey` inherits the same known
limitation (not a new regression) — flag during implementation.
**Shared settings** (`ReconnectInterval`, `TagResolutionRetryInterval`,
`WriteTimeout`) stay in `DataConnectionOptions`, unchanged, applying to all protocols.
## Section 3 — Protocol-agnostic browse (tag picker)
`IBrowsableDataConnection` is already protocol-neutral (node ids are opaque strings).
Generalize the OPC-UA-named plumbing so both protocols flow through one path.
**Renames (site + central + UI):**
| Today | Becomes |
|---|---|
| `BrowseOpcUaNodeCommand` / `BrowseOpcUaNodeResult` | `BrowseNodeCommand` / `BrowseNodeResult` |
| `OpcUaBrowseService` / `IOpcUaBrowseService` | `BrowseService` / `IBrowseService` |
| `OpcUaBrowserDialog.razor` | `NodeBrowserDialog.razor` |
| `BrowseFailure` / `BrowseFailureKind` | kept (already generic) |
`DataConnectionManagerActor` resolves the connection, checks
`adapter is IBrowsableDataConnection`, and calls `BrowseChildrenAsync(parentNodeId)`
regardless of protocol (already the OPC UA logic — just drop the "OpcUa" from names).
Adapters without the interface return a "browse not supported" failure (unchanged).
**MxGateway side.** `MxGatewayDataConnection.BrowseChildrenAsync` wraps
`GalaxyRepositoryClient.BrowseChildrenAsync` (one Galaxy level per call). Mapping:
- Galaxy object → `BrowseNode(NodeId = gobjectId-or-contained-path,
DisplayName = tagName, NodeClass = Object, HasChildren = child_has_children[i])`.
- Each object's attributes → `BrowseNode(NodeId = FullTagReference,
NodeClass = Variable, HasChildren = false)` — Variable rows are the selectable tag
paths stored in instance config.
`GalaxyRepositoryClient` is a separate gRPC client from `MxGatewayClient`, so the
adapter holds both (same Endpoint + ApiKey): browse uses the read-only repository
client, the hot path uses the gateway client. The tag-picker dialog opens identically
for either protocol; only the tree shape and opaque node-id strings differ.
## Section 4 — Packaging, DI registration & error classification
**NuGet feed.** Add a repo-root `nuget.config` declaring the Gitea feed
(`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`) alongside
nuget.org. Credentials are **not** committed — from the developer's `~/.nuget`, or
for the Docker image build a build-arg/secret-mounted credential (wire into
`docker/deploy.sh`). The DCL project references `ZB.MOM.WW.MxGateway.Client`
(`…Contracts` transitively); both target net10.0.
**DI registration** in `DataConnectionFactory`:
```csharp
RegisterAdapter("MxGateway", details => new MxGatewayDataConnection(
new MxGatewayClientFactory(_loggerFactory),
_loggerFactory.CreateLogger<MxGatewayDataConnection>()));
```
plus an `MxGatewayGlobalOptions` (parallel to `OpcUaGlobalOptions`) bound in Host.
OPC UA registration untouched.
**Error classification** (drives bad-quality push vs. synchronous script error):
- *Connection/transport faults* (`MxGatewaySessionException`, gRPC unavailable, stream
break) → `Disconnected` → reconnect + bad quality. Transient.
- *Per-item read/write failures* (`BulkReadResult` / `BulkWriteResult` with
`WasSuccessful = false`: bad tag, MXAccess rejection) → returned to caller (write) or
bad quality (read). Not a disconnect.
- *Auth failures* (`MxGatewayAuthenticationException` / `…AuthorizationException`) →
treated like a failed connect (logged, retried on failover/reconnect cadence); a
rotated key is operationally a connection problem, not per-tag.
Matches OPC UA's "operations fail immediately to the caller; connection loss triggers
reconnect" split.
## Section 5 — Testing, docs & deploy
**Testing** (fake client seam, no live gateway, following the OPC UA adapter style):
- `MxGatewayDataConnection` against a `FakeMxGatewayClient`: connect→register→advise
lifecycle; `OnDataChange` → `TagValue` dispatch incl. quality mapping; read/write/batch
success + per-item failure; `WriteBatchAndWait` match & timeout; `Disconnected` fires
once on stream fault; `worker_sequence` resume on reconnect.
- `MxGatewayEndpointConfigSerializer` / `Validator` round-trip + defaults +
invalid-numeric fallback.
- Browse mapping (object→Object, attribute→Variable, `HasChildren` hint) against a fake
repository client.
- Generalized-browse regression: existing OPC UA browse tests updated to renamed
`BrowseNodeCommand` / `BrowseService` and still passing.
**Docs (spec travels with code):**
- `Component-DataConnectionLayer.md`: add MxGateway under "Supported Protocols", an
"MxGateway Settings" config table, note `IBrowsableDataConnection` now backs both
protocols.
- `README.md` protocol mentions if any.
- This design doc.
**Deploy.** `bash docker/deploy.sh` rebuilds the image; only deploy-config change is
NuGet credential wiring for restore. Sites get the adapter automatically (compiled into
Host). No new ports/services — the adapter is an outbound gRPC client to the gateway.
**Affected components:** DCL (adapter, factory, options), Commons (config type,
serializer, validator, renamed browse messages + `IBrowsableDataConnection`
consumers), Configuration Database (none — no schema change), Central UI (renamed
browse service/dialog, protocol selector + `MxGatewayEndpointEditor` in
`DataConnectionForm` — net-new UI, use `frontend-design` skill), Host (options
binding), tests, docs, `nuget.config`.
@@ -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 &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.
@@ -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": "completed"},
{"id": 7, "planTask": 2, "subject": "Task 2: MxGatewayEndpointConfig type", "status": "completed"},
{"id": 8, "planTask": 3, "subject": "Task 3: MxGatewayEndpointConfigSerializer + tests", "status": "completed", "blockedBy": [7]},
{"id": 9, "planTask": 4, "subject": "Task 4: MxGatewayEndpointConfigValidator + tests", "status": "completed", "blockedBy": [7]},
{"id": 10, "planTask": 5, "subject": "Task 5: Client seam interfaces + MxGatewayGlobalOptions", "status": "completed"},
{"id": 11, "planTask": 6, "subject": "Task 6: Adapter connect/disconnect/Disconnected + value mapping", "status": "completed", "blockedBy": [7, 10]},
{"id": 12, "planTask": 7, "subject": "Task 7: Adapter subscribe/unsubscribe + event routing", "status": "completed", "blockedBy": [11]},
{"id": 13, "planTask": 8, "subject": "Task 8: Adapter read/write batch + error classification", "status": "completed", "blockedBy": [11]},
{"id": 14, "planTask": 9, "subject": "Task 9: Adapter WriteBatchAndWaitAsync", "status": "completed", "blockedBy": [13]},
{"id": 15, "planTask": 10, "subject": "Task 10: Adapter Galaxy browse (IBrowsableDataConnection)", "status": "completed", "blockedBy": [11]},
{"id": 16, "planTask": 11, "subject": "Task 11: RealMxGatewayClient seam implementation", "status": "completed", "blockedBy": [6, 10]},
{"id": 17, "planTask": 12, "subject": "Task 12: Factory registration + options binding + flatten branch", "status": "completed", "blockedBy": [8, 16, 18]},
{"id": 18, "planTask": 13, "subject": "Task 13: Rename browse message types to protocol-agnostic", "status": "completed"},
{"id": 19, "planTask": 14, "subject": "Task 14: Rename browse service + dialog to protocol-agnostic", "status": "completed", "blockedBy": [18]},
{"id": 20, "planTask": 15, "subject": "Task 15: MxGatewayEndpointEditor.razor", "status": "completed", "blockedBy": [8]},
{"id": 21, "planTask": 16, "subject": "Task 16: Protocol selector in DataConnectionForm", "status": "completed", "blockedBy": [8, 20, 19]},
{"id": 22, "planTask": 17, "subject": "Task 17: Verify MxGateway tag picker on Configure Instance", "status": "completed", "blockedBy": [19]},
{"id": 23, "planTask": 18, "subject": "Task 18: Documentation", "status": "completed", "blockedBy": [17]},
{"id": 24, "planTask": 19, "subject": "Task 19: Full build, test suite, deploy smoke", "status": "completed", "blockedBy": [17, 21, 22, 23]}
],
"lastUpdated": "2026-05-28 (all tasks complete)"
}
@@ -58,6 +58,14 @@ All protocols produce the same value tuple consumed by Instance Actors. Before t
- Read/Write via OPC UA Read/Write services with StatusCode-based quality mapping. - Read/Write via OPC UA Read/Write services with StatusCode-based quality mapping.
- Disconnect detection via `Session.KeepAlive` event (see Disconnect Detection Pattern below). - Disconnect detection via `Session.KeepAlive` event (see Disconnect Detection Pattern below).
### MxGateway
- Connects to the **MxAccess Gateway** (AVEVA/Wonderware MXAccess-backed Galaxy) over gRPC using the `ZB.MOM.WW.MxGateway.Client` NuGet package (from the Gitea feed); `ZB.MOM.WW.MxGateway.Contracts` is pulled in transitively.
- Session-based: `OpenSession` + `Register` on connect; `AddItem` + `Advise` per subscription; value changes arrive on the gateway's server-streaming event feed (`StreamEvents`), resumable via `worker_sequence`.
- Read/Write via `ReadBulk` / `WriteBulk`; writes carry a configurable `WriteUserId`. Quality maps the OPC-style quality byte (≥192 Good, ≥64 Uncertain, else Bad), with a failing MXAccess status proxy treated as Bad.
- Galaxy hierarchy browse via the separate `GalaxyRepositoryClient` (`BrowseChildren`) — objects are navigable nodes (keyed by Galaxy gobject id), attributes are selectable leaves (keyed by full tag reference).
- Disconnect detection: a fault on the event stream raises `IDataConnection.Disconnected`, driving the same reconnection state machine as OPC UA.
- Implemented as `MxGatewayDataConnection` over an `IMxGatewayClient` seam; the seam is decoupled from the generated gRPC types (only `RealMxGatewayClient` references them), so the adapter is fully unit-testable with a fake.
## Endpoint Redundancy ## Endpoint Redundancy
Data connections support an optional backup endpoint for automatic failover when the active endpoint becomes unreachable. Both endpoints use the same protocol. Data connections support an optional backup endpoint for automatic failover when the active endpoint becomes unreachable. Both endpoints use the same protocol.
@@ -115,6 +123,21 @@ All settings are parsed from the data connection's configuration JSON dictionari
| `SecurityMode` | string | `None` | Preferred endpoint security: `None`, `Sign`, or `SignAndEncrypt` | | `SecurityMode` | string | `None` | Preferred endpoint security: `None`, `Sign`, or `SignAndEncrypt` |
| `AutoAcceptUntrustedCerts` | bool | `true` | Accept untrusted server certificates | | `AutoAcceptUntrustedCerts` | bool | `true` | Accept untrusted server certificates |
### MxGateway Settings
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `Endpoint` | string | `http://localhost:5000` | Gateway base URL |
| `ApiKey` | string | — | Sent to the gateway as `authorization: Bearer <key>` |
| `ClientName` | string | `scadabridge` (when blank) | MXAccess client registration name |
| `WriteUserId` | int | `0` | MXAccess user id applied to every write-back (0 = no user context) |
| `UseTls` | bool | `false` | Use TLS to a secured gateway |
| `CaFile` | string | — | Path to the CA certificate (TLS only) |
| `ServerName` | string | — | TLS server-name override |
| `ReadTimeoutMs` | int | `5000` | `ReadBulk` per-call timeout in milliseconds |
Secret handling for `ApiKey` follows the same at-rest treatment and log/telemetry redaction as the OPC UA `UserIdentity` username/password fields.
### Shared Settings (appsettings.json) ### Shared Settings (appsettings.json)
These are configured via `DataConnectionOptions` in `appsettings.json`, not per-connection: These are configured via `DataConnectionOptions` in `appsettings.json`, not per-connection:
@@ -142,10 +165,11 @@ These are configured via `DataConnectionOptions` in `appsettings.json`, not per-
## Browsing the address space ## Browsing the address space
DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for protocols that support it, exposed via `IBrowsableDataConnection`. Only consumed by management/UI (the OPC UA tag picker on the instance configure page); Instance Actors never call it. DCL is a clean data pipe on the hot path. Browse is an **opt-in capability** for protocols that support it, exposed via `IBrowsableDataConnection`. Only consumed by management/UI (the tag picker on the instance configure page); Instance Actors never call it. The browse path is **protocol-agnostic**: the same command/service/dialog serve every browsable protocol.
- `OpcUaDataConnection` implements `IBrowsableDataConnection`; custom protocols do not. - `OpcUaDataConnection` and `MxGatewayDataConnection` both implement `IBrowsableDataConnection`; other/custom protocols do not (and return a `NotBrowsable` failure).
- `DataConnectionManagerActor` handles `BrowseOpcUaNodeCommand` (fields: `DataConnectionId`, `ParentNodeId`) and replies with `BrowseOpcUaNodeResult` (children + `Truncated` + structured `BrowseFailure?`). - `DataConnectionManagerActor` handles `BrowseNodeCommand` (fields: `ConnectionName`, `ParentNodeId`) and replies with `BrowseNodeResult` (children + `Truncated` + structured `BrowseFailure?`). The Central UI facade is `IBrowseService`/`BrowseService`, backing the `NodeBrowserDialog` tag picker.
- Node ids are opaque protocol-specific strings: OPC UA uses NodeIds; MxGateway uses Galaxy gobject ids for navigable objects and full tag references for selectable attribute leaves.
- Browse runs against the live session; no caching at DCL. - Browse runs against the live session; no caching at DCL.
## Value Update Message Format ## Value Update Message Format
+27
View File
@@ -0,0 +1,27 @@
<?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>
<!--
Central package management requires explicit source mapping when more than
one feed is defined: nuget.org serves everything; the Gitea feed serves only
the ZB.MOM.WW.MxGateway.* packages.
-->
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="dohertj2-gitea">
<package pattern="ZB.MOM.WW.MxGateway.*" />
</packageSource>
</packageSourceMapping>
<!--
Credentials are NOT committed. Provide them per-developer by running
`dotnet nuget add source` for the Gitea feed (with username + access token
and the store-password-in-clear-text flag), or supply NuGet credential
env vars in CI / the docker build (see docker/deploy.sh wiring in Task 19).
-->
</configuration>
@@ -1,7 +1,7 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management @using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services @using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@inject IOpcUaBrowseService BrowseService @inject IBrowseService BrowseService
@if (_isVisible) @if (_isVisible)
{ {
@@ -9,7 +9,7 @@
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Browse OPC UA — @ConnectionName</h5> <h5 class="modal-title">Browse — @ConnectionName</h5>
<button type="button" class="btn-close" @onclick="Cancel"></button> <button type="button" class="btn-close" @onclick="Cancel"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -143,7 +143,7 @@
/// <summary> /// <summary>
/// Opens the dialog and triggers an immediate one-shot read. Method-arg /// Opens the dialog and triggers an immediate one-shot read. Method-arg
/// pattern (mirroring <c>OpcUaBrowserDialog.ShowAsync</c>) — Razor /// pattern (mirroring <c>NodeBrowserDialog.ShowAsync</c>) — Razor
/// parameter binding would propagate on the next render and race the /// parameter binding would propagate on the next render and race the
/// LoadAsync below. /// LoadAsync below.
/// </summary> /// </summary>
@@ -46,8 +46,8 @@
</li> </li>
@code { @code {
[Parameter] public OpcUaBrowserDialog.TreeNode Node { get; set; } = default!; [Parameter] public NodeBrowserDialog.TreeNode Node { get; set; } = default!;
[Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> OnToggle { get; set; } [Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnToggle { get; set; }
[Parameter] public EventCallback<OpcUaBrowserDialog.TreeNode> OnSelect { get; set; } [Parameter] public EventCallback<NodeBrowserDialog.TreeNode> OnSelect { get; set; }
[Parameter] public string? SelectedNodeId { get; set; } [Parameter] public string? SelectedNodeId { get; set; }
} }
@@ -0,0 +1,89 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Forms
@using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
<div class="mxgateway-endpoint-editor">
<h6 class="text-muted border-bottom pb-1">@Title</h6>
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Gateway endpoint</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.Endpoint"
placeholder="http://host:5000" />
@RenderFieldError("Endpoint")
</div>
<div class="col-md-5">
<label class="form-label small">API key</label>
<input type="password" class="form-control form-control-sm"
@bind="Config.ApiKey"
placeholder="gateway API key" />
@RenderFieldError("ApiKey")
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-5">
<label class="form-label small">Client name</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.ClientName"
placeholder="(defaults to scadabridge)" />
</div>
<div class="col-md-3">
<label class="form-label small">Write user id</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.WriteUserId" min="0" />
</div>
<div class="col-md-4">
<label class="form-label small">Read timeout (ms)</label>
<input type="number" class="form-control form-control-sm"
@bind="Config.ReadTimeoutMs" min="1" />
@RenderFieldError("ReadTimeoutMs")
</div>
</div>
<div class="text-muted small mt-2 mb-1">Transport security</div>
<div class="row g-2 mb-2">
<div class="col-md-2 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox"
id="@($"{IdPrefix}-usetls")"
@bind="Config.UseTls" />
<label class="form-check-label small"
for="@($"{IdPrefix}-usetls")">Use TLS</label>
</div>
</div>
@if (Config.UseTls)
{
<div class="col-md-6">
<label class="form-label small">CA certificate path</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.CaFile"
placeholder="/etc/scadabridge/pki/gateway-ca.pem" />
</div>
<div class="col-md-4">
<label class="form-label small">Server name override</label>
<input type="text" class="form-control form-control-sm"
@bind="Config.ServerName"
placeholder="gateway.example.local" />
</div>
}
</div>
</div>
@code {
[Parameter, EditorRequired] public MxGatewayEndpointConfig Config { get; set; } = default!;
[Parameter] public string Title { get; set; } = "Endpoint";
[Parameter] public string IdPrefix { get; set; } = "mxgateway-endpoint";
[Parameter] public ValidationResult? Errors { get; set; }
private RenderFragment? RenderFieldError(string field)
{
var match = Errors?.Errors.FirstOrDefault(e =>
e.EntityName != null
&& (e.EntityName == field || e.EntityName.EndsWith("." + field)));
return match is null
? null
: @<div class="text-danger small">@match.Message</div>;
}
}
@@ -118,7 +118,7 @@
{ {
var connId = GetBindingConnectionId(attr.Name); var connId = GetBindingConnectionId(attr.Name);
var canBrowse = connId > 0; var canBrowse = connId > 0;
var isOpcUa = IsOpcUa(connId); var isBrowsable = IsBrowsable(connId);
<tr> <tr>
<td class="small">@attr.Name</td> <td class="small">@attr.Name</td>
<td class="small text-muted font-monospace">@attr.DataSourceReference</td> <td class="small text-muted font-monospace">@attr.DataSourceReference</td>
@@ -140,11 +140,11 @@
placeholder="@(attr.DataSourceReference ?? "(no default)")" /> placeholder="@(attr.DataSourceReference ?? "(no default)")" />
</td> </td>
<td> <td>
@if (isOpcUa) @if (isBrowsable)
{ {
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
disabled="@(!canBrowse)" disabled="@(!canBrowse)"
title="@(canBrowse ? "Browse OPC UA address space" : "Pick a connection first")" title="@(canBrowse ? "Browse address space" : "Pick a connection first")"
@onclick="() => OpenBrowser(attr.Name)"> @onclick="() => OpenBrowser(attr.Name)">
Browse… Browse…
</button> </button>
@@ -367,7 +367,7 @@
@* OPC UA Tag Browser dialog (Task 18) — rendered once; OpenBrowser @* OPC UA Tag Browser dialog (Task 18) — rendered once; OpenBrowser
tracks which binding row's override input receives the picked node id. *@ tracks which binding row's override input receives the picked node id. *@
<OpcUaBrowserDialog @ref="_browserRef" <NodeBrowserDialog @ref="_browserRef"
SiteId="@_browserSiteIdentifier" SiteId="@_browserSiteIdentifier"
ConnectionName="@_browserConnectionName" ConnectionName="@_browserConnectionName"
InitialNodeId="@_browserInitial" InitialNodeId="@_browserInitial"
@@ -375,7 +375,7 @@
@* Test Bindings dialog — one-shot live read of every bound attribute. @* Test Bindings dialog — one-shot live read of every bound attribute.
Method-arg ShowAsync(siteId, rows) — no Razor parameter propagation Method-arg ShowAsync(siteId, rows) — no Razor parameter propagation
race (same pattern as OpcUaBrowserDialog). *@ race (same pattern as NodeBrowserDialog). *@
<TestBindingsDialog @ref="_testBindingsRef" /> <TestBindingsDialog @ref="_testBindingsRef" />
} }
</div> </div>
@@ -407,7 +407,7 @@
// OPC UA tag browser (Task 18) — single dialog rendered at page bottom; // OPC UA tag browser (Task 18) — single dialog rendered at page bottom;
// _browserAttrInEdit tracks which row gets the picked node id on Select. // _browserAttrInEdit tracks which row gets the picked node id on Select.
private OpcUaBrowserDialog? _browserRef; private NodeBrowserDialog? _browserRef;
private string? _browserAttrInEdit; private string? _browserAttrInEdit;
private string _browserSiteIdentifier = ""; private string _browserSiteIdentifier = "";
private string _browserConnectionName = ""; private string _browserConnectionName = "";
@@ -566,13 +566,19 @@
private string? GetTemplateDefault(string attrName) private string? GetTemplateDefault(string attrName)
=> _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference; => _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference;
/// <summary>True when the row's selected data connection is OPC UA.</summary> /// <summary>
private bool IsOpcUa(int connectionId) /// True when the row's selected data connection supports address-space browsing
=> connectionId > 0 /// (the tag picker). OPC UA and MxGateway both implement
&& string.Equals( /// <c>IBrowsableDataConnection</c> site-side; other protocols return a
_siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol, /// NotBrowsable failure, so the button is hidden for them.
"OpcUa", /// </summary>
StringComparison.OrdinalIgnoreCase); private bool IsBrowsable(int connectionId)
{
if (connectionId <= 0) return false;
var protocol = _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol;
return string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase);
}
/// <summary> /// <summary>
/// Opens the OPC UA tag browser dialog for the given attribute row. Remembers /// Opens the OPC UA tag browser dialog for the given attribute row. Remembers
@@ -49,17 +49,45 @@
</select> </select>
} }
</div> </div>
<div class="mb-2">
<label class="form-label small">Protocol</label>
@if (_protocolLocked)
{
<input type="text"
class="form-control form-control-plaintext form-control-sm"
readonly
value="@_protocol" />
<div class="form-text">Protocol is locked after creation.</div>
}
else
{
<select class="form-select form-select-sm" @bind="_protocol">
<option value="OpcUa">OPC UA</option>
<option value="MxGateway">MxGateway</option>
</select>
}
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label small">Name</label> <label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_formName" /> <input type="text" class="form-control form-control-sm" @bind="_formName" />
</div> </div>
<h6 class="text-muted mt-3">Primary endpoint</h6> <h6 class="text-muted mt-3">Primary endpoint</h6>
<OpcUaEndpointEditor Title="Primary Endpoint" @if (_protocol == "MxGateway")
IdPrefix="primary" {
Config="_primaryConfig" <MxGatewayEndpointEditor Title="Primary Endpoint"
IsLegacy="_primaryIsLegacy" IdPrefix="primary"
Errors="_primaryErrors" /> Config="_primaryMx"
Errors="_primaryErrors" />
}
else
{
<OpcUaEndpointEditor Title="Primary Endpoint"
IdPrefix="primary"
Config="_primaryConfig"
IsLegacy="_primaryIsLegacy"
Errors="_primaryErrors" />
}
<h6 class="text-muted mt-3"> <h6 class="text-muted mt-3">
Backup endpoint Backup endpoint
@@ -77,11 +105,21 @@
} }
else else
{ {
<OpcUaEndpointEditor Title="Backup Endpoint" @if (_protocol == "MxGateway")
IdPrefix="backup" {
Config="_backupConfig" <MxGatewayEndpointEditor Title="Backup Endpoint"
IsLegacy="_backupIsLegacy" IdPrefix="backup"
Errors="_backupErrors" /> Config="_backupMx"
Errors="_backupErrors" />
}
else
{
<OpcUaEndpointEditor Title="Backup Endpoint"
IdPrefix="backup"
Config="_backupConfig"
IsLegacy="_backupIsLegacy"
Errors="_backupErrors" />
}
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Failover Retry Count</label> <label class="form-label small">Failover Retry Count</label>
<input type="number" class="form-control form-control-sm" style="max-width: 120px;" <input type="number" class="form-control form-control-sm" style="max-width: 120px;"
@@ -118,8 +156,12 @@
private string _siteName = string.Empty; private string _siteName = string.Empty;
private bool _siteLocked; private bool _siteLocked;
private string _formName = string.Empty; private string _formName = string.Empty;
private string _protocol = "OpcUa";
private bool _protocolLocked;
private OpcUaEndpointConfig _primaryConfig = new(); private OpcUaEndpointConfig _primaryConfig = new();
private OpcUaEndpointConfig _backupConfig = new(); private OpcUaEndpointConfig _backupConfig = new();
private MxGatewayEndpointConfig _primaryMx = new();
private MxGatewayEndpointConfig _backupMx = new();
private bool _primaryIsLegacy; private bool _primaryIsLegacy;
private bool _backupIsLegacy; private bool _backupIsLegacy;
private bool _showBackup; private bool _showBackup;
@@ -143,17 +185,10 @@
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}"; _siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
_siteLocked = true; _siteLocked = true;
_formName = _editingConnection.Name; _formName = _editingConnection.Name;
_protocol = string.IsNullOrWhiteSpace(_editingConnection.Protocol) ? "OpcUa" : _editingConnection.Protocol;
_protocolLocked = true;
(_primaryConfig, _primaryIsLegacy) = LoadConfig(_editingConnection);
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
}
} }
} }
else if (SiteId.HasValue) else if (SiteId.HasValue)
@@ -177,32 +212,80 @@
} }
} }
private void LoadConfig(DataConnection conn)
{
if (_protocol == "MxGateway")
{
_primaryMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
{
_backupMx = MxGatewayEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = conn.FailoverRetryCount;
}
}
else
{
(_primaryConfig, _primaryIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(conn.PrimaryConfiguration);
if (!string.IsNullOrWhiteSpace(conn.BackupConfiguration))
{
(_backupConfig, _backupIsLegacy) =
OpcUaEndpointConfigSerializer.Deserialize(conn.BackupConfiguration);
_showBackup = true;
_formFailoverRetryCount = conn.FailoverRetryCount;
}
}
}
private async Task SaveConnection() private async Task SaveConnection()
{ {
_formError = null; _formError = null;
if (_formSiteId == 0) { _formError = "Site is required."; return; } if (_formSiteId == 0) { _formError = "Site is required."; return; }
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary."); string primaryJson;
_backupErrors = _showBackup string? backupJson;
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false })) if (_protocol == "MxGateway")
{ {
_formError = "Fix the errors below before saving."; _primaryErrors = MxGatewayEndpointConfigValidator.Validate(_primaryMx, "Primary.");
return; _backupErrors = _showBackup
} ? MxGatewayEndpointConfigValidator.Validate(_backupMx, "Backup.")
: null;
var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig); if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null; {
_formError = "Fix the errors below before saving.";
return;
}
primaryJson = MxGatewayEndpointConfigSerializer.Serialize(_primaryMx);
backupJson = _showBackup ? MxGatewayEndpointConfigSerializer.Serialize(_backupMx) : null;
}
else
{
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
_backupErrors = _showBackup
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
: null;
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
{
_formError = "Fix the errors below before saving.";
return;
}
primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
}
try try
{ {
if (_editingConnection != null) if (_editingConnection != null)
{ {
_editingConnection.Name = _formName.Trim(); _editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = "OpcUa"; _editingConnection.Protocol = _protocol;
_editingConnection.PrimaryConfiguration = primaryJson; _editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson; _editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3; _editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
@@ -210,7 +293,7 @@
} }
else else
{ {
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId) var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId)
{ {
PrimaryConfiguration = primaryJson, PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson, BackupConfiguration = backupJson,
@@ -233,6 +316,7 @@
{ {
_showBackup = false; _showBackup = false;
_backupConfig = new OpcUaEndpointConfig(); _backupConfig = new OpcUaEndpointConfig();
_backupMx = new MxGatewayEndpointConfig();
_backupIsLegacy = false; _backupIsLegacy = false;
_formFailoverRetryCount = 3; _formFailoverRetryCount = 3;
} }
@@ -50,10 +50,10 @@ public static class ServiceCollectionExtensions
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export. // Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
services.AddScoped<IAuditLogExportService, AuditLogExportService>(); services.AddScoped<IAuditLogExportService, AuditLogExportService>();
// OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseOpcUaNodeAsync // OPC UA Tag Browser (Task 14): facade over CommunicationService.BrowseNodeAsync
// that enforces the CentralUI-side Design-role trust boundary and translates // that enforces the CentralUI-side Design-role trust boundary and translates
// transport failures into typed BrowseFailure results for the dialog. // transport failures into typed BrowseFailure results for the dialog.
services.AddScoped<IOpcUaBrowseService, OpcUaBrowseService>(); services.AddScoped<IBrowseService, BrowseService>();
// Test Bindings: facade over CommunicationService.ReadTagValuesAsync — // Test Bindings: facade over CommunicationService.ReadTagValuesAsync —
// same Design-role guard + typed-failure translation as the browse // same Design-role guard + typed-failure translation as the browse
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <see cref="CommunicationService.ReadTagValuesAsync"/> that enforces the /// <see cref="CommunicationService.ReadTagValuesAsync"/> that enforces the
/// CentralUI-side <c>Design</c>-role trust boundary and translates transport /// CentralUI-side <c>Design</c>-role trust boundary and translates transport
/// exceptions into a typed <see cref="ReadTagValuesFailure"/> result. Mirrors /// exceptions into a typed <see cref="ReadTagValuesFailure"/> result. Mirrors
/// <see cref="OpcUaBrowseService"/>. /// <see cref="BrowseService"/>.
/// </summary> /// </summary>
public sealed class BindingTester : IBindingTester public sealed class BindingTester : IBindingTester
{ {
@@ -7,8 +7,8 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary> /// <summary>
/// Default <see cref="IOpcUaBrowseService"/> implementation — a thin facade over /// Default <see cref="IBrowseService"/> implementation — a thin facade over
/// <see cref="CommunicationService.BrowseOpcUaNodeAsync"/> that enforces the /// <see cref="CommunicationService.BrowseNodeAsync"/> that enforces the
/// CentralUI-side <c>Design</c>-role trust boundary and translates transport /// CentralUI-side <c>Design</c>-role trust boundary and translates transport
/// exceptions into a typed <see cref="BrowseFailure"/> result. /// exceptions into a typed <see cref="BrowseFailure"/> result.
/// </summary> /// </summary>
@@ -19,24 +19,24 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <c>ServerError</c> so the dialog can show an inline banner while leaving the /// <c>ServerError</c> so the dialog can show an inline banner while leaving the
/// manual node-id paste field usable. /// manual node-id paste field usable.
/// </remarks> /// </remarks>
public sealed class OpcUaBrowseService : IOpcUaBrowseService public sealed class BrowseService : IBrowseService
{ {
private readonly CommunicationService _communication; private readonly CommunicationService _communication;
private readonly AuthenticationStateProvider _auth; private readonly AuthenticationStateProvider _auth;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="OpcUaBrowseService"/>. /// Initializes a new instance of the <see cref="BrowseService"/>.
/// </summary> /// </summary>
/// <param name="communication">Central-side cluster communication service.</param> /// <param name="communication">Central-side cluster communication service.</param>
/// <param name="auth">Authentication state provider used for the Design-role guard.</param> /// <param name="auth">Authentication state provider used for the Design-role guard.</param>
public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth) public BrowseService(CommunicationService communication, AuthenticationStateProvider auth)
{ {
_communication = communication ?? throw new ArgumentNullException(nameof(communication)); _communication = communication ?? throw new ArgumentNullException(nameof(communication));
_auth = auth ?? throw new ArgumentNullException(nameof(auth)); _auth = auth ?? throw new ArgumentNullException(nameof(auth));
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<BrowseOpcUaNodeResult> BrowseChildrenAsync( public async Task<BrowseNodeResult> BrowseChildrenAsync(
string siteId, string siteId,
string connectionName, string connectionName,
string? parentNodeId, string? parentNodeId,
@@ -47,7 +47,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
var state = await _auth.GetAuthenticationStateAsync(); var state = await _auth.GetAuthenticationStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design"))
{ {
return new BrowseOpcUaNodeResult( return new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized.")); new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized."));
@@ -55,9 +55,9 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
try try
{ {
return await _communication.BrowseOpcUaNodeAsync( return await _communication.BrowseNodeAsync(
siteId, siteId,
new BrowseOpcUaNodeCommand(connectionName, parentNodeId), new BrowseNodeCommand(connectionName, parentNodeId),
cancellationToken); cancellationToken);
} }
catch (TimeoutException ex) catch (TimeoutException ex)
@@ -65,7 +65,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
// Akka Ask timed out — the site (or its OPC UA session) didn't answer // Akka Ask timed out — the site (or its OPC UA session) didn't answer
// within CommunicationOptions.QueryTimeout. Surface as a typed // within CommunicationOptions.QueryTimeout. Surface as a typed
// Timeout failure so the dialog can render an inline banner. // Timeout failure so the dialog can render an inline banner.
return new BrowseOpcUaNodeResult( return new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, ex.Message)); new BrowseFailure(BrowseFailureKind.Timeout, ex.Message));
@@ -80,7 +80,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService
{ {
// Any other transport / serialization failure: keep the dialog // Any other transport / serialization failure: keep the dialog
// alive and let the user fall back to manual node-id paste. // alive and let the user fall back to manual node-id paste.
return new BrowseOpcUaNodeResult( return new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure(BrowseFailureKind.ServerError, ex.Message)); new BrowseFailure(BrowseFailureKind.ServerError, ex.Message));
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// envelope. Transport failures (timeouts, unreachable sites) are translated /// envelope. Transport failures (timeouts, unreachable sites) are translated
/// into a typed <see cref="ReadTagValuesFailure"/> so the dialog can render an /// into a typed <see cref="ReadTagValuesFailure"/> so the dialog can render an
/// inline banner without crashing — same shape as /// inline banner without crashing — same shape as
/// <see cref="IOpcUaBrowseService"/>. /// <see cref="IBrowseService"/>.
/// </remarks> /// </remarks>
public interface IBindingTester public interface IBindingTester
{ {
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// CentralUI facade over the central-to-site OPC UA browse command. Backs the /// CentralUI facade over the central-to-site OPC UA browse command. Backs the
/// OPC UA Tag Browser dialog: each tree expansion / manual node-id paste calls /// OPC UA Tag Browser dialog: each tree expansion / manual node-id paste calls
/// <see cref="BrowseChildrenAsync"/>, which forwards a /// <see cref="BrowseChildrenAsync"/>, which forwards a
/// <see cref="BrowseOpcUaNodeCommand"/> to the owning site via /// <see cref="BrowseNodeCommand"/> to the owning site via
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService"/>. /// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <see cref="BrowseFailure"/> so the dialog can render an inline error and /// <see cref="BrowseFailure"/> so the dialog can render an inline error and
/// remain usable (manual node-id paste still works). /// remain usable (manual node-id paste still works).
/// </remarks> /// </remarks>
public interface IOpcUaBrowseService public interface IBrowseService
{ {
/// <summary> /// <summary>
/// Enumerates the immediate children of an OPC UA node on the live server /// Enumerates the immediate children of an OPC UA node on the live server
@@ -29,7 +29,7 @@ public interface IOpcUaBrowseService
/// <param name="connectionName">Name of the site-local data connection to browse against — the site's <c>DataConnectionManagerActor</c> indexes its children by name.</param> /// <param name="connectionName">Name of the site-local data connection to browse against — the site's <c>DataConnectionManagerActor</c> indexes its children by name.</param>
/// <param name="parentNodeId">Node to browse, or <c>null</c> to browse from the server root.</param> /// <param name="parentNodeId">Node to browse, or <c>null</c> to browse from the server root.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task<BrowseOpcUaNodeResult> BrowseChildrenAsync( Task<BrowseNodeResult> BrowseChildrenAsync(
string siteId, string siteId,
string connectionName, string connectionName,
string? parentNodeId, string? parentNodeId,
@@ -15,11 +15,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// </remarks> /// </remarks>
/// <param name="ConnectionName">Name of the site-local data connection to browse against.</param> /// <param name="ConnectionName">Name of the site-local data connection to browse against.</param>
/// <param name="ParentNodeId">Node to browse, or null to browse from the server root (ObjectsFolder).</param> /// <param name="ParentNodeId">Node to browse, or null to browse from the server root (ObjectsFolder).</param>
public record BrowseOpcUaNodeCommand( public record BrowseNodeCommand(
string ConnectionName, string ConnectionName,
string? ParentNodeId); string? ParentNodeId);
public record BrowseOpcUaNodeResult( public record BrowseNodeResult(
IReadOnlyList<BrowseNode> Children, IReadOnlyList<BrowseNode> Children,
bool Truncated, bool Truncated,
BrowseFailure? Failure); BrowseFailure? Failure);
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Keyed by <see cref="ConnectionName"/> (not id) for the same reason as /// Keyed by <see cref="ConnectionName"/> (not id) for the same reason as
/// <see cref="BrowseOpcUaNodeCommand"/>: the site-side /// <see cref="BrowseNodeCommand"/>: the site-side
/// <c>DataConnectionManagerActor</c> indexes its children by connection name, /// <c>DataConnectionManagerActor</c> indexes its children by connection name,
/// and the central UI already has the connection name in scope from the /// and the central UI already has the connection name in scope from the
/// bindings table. The central <c>DataConnections</c> table's id is not /// bindings table. The central <c>DataConnections</c> table's id is not
@@ -0,0 +1,65 @@
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 — a row that
/// fails to parse yields a default config.
/// </summary>
public static class MxGatewayEndpointConfigSerializer
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
/// <summary>Serializes a config to the typed JSON shape.</summary>
/// <param name="config">The endpoint configuration to serialize.</param>
public static string Serialize(MxGatewayEndpointConfig config)
=> JsonSerializer.Serialize(config, JsonOpts);
/// <summary>Parses stored config JSON; null/blank/malformed yields a default config.</summary>
/// <param name="json">The stored JSON string.</param>
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(); }
}
/// <summary>Flattens the typed config to the key-value shape the adapter consumes.</summary>
/// <param name="c">The endpoint configuration to flatten.</param>
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),
};
/// <summary>Reconstructs a config from the flat key-value shape; invalid numerics fall back to defaults.</summary>
/// <param name="d">The flat dictionary.</param>
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;
}
}
@@ -0,0 +1,27 @@
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;
}
@@ -0,0 +1,46 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.Commons.Validators;
/// <summary>
/// Pure-function validator for <see cref="MxGatewayEndpointConfig"/>. Errors carry
/// the offending property name in <see cref="ValidationEntry.EntityName"/>
/// (optionally prefixed, e.g. "Primary.Endpoint") so the form can render
/// per-field messages.
/// </summary>
public static class MxGatewayEndpointConfigValidator
{
/// <summary>
/// Validates all fields of an <see cref="MxGatewayEndpointConfig"/>, returning errors with optionally-prefixed field names.
/// </summary>
/// <param name="config">The MxGateway endpoint configuration to validate.</param>
/// <param name="fieldPrefix">Optional prefix prepended to each field name in error entries (e.g., "Primary.").</param>
public static ValidationResult Validate(MxGatewayEndpointConfig config, string fieldPrefix = "")
{
var errors = new List<ValidationEntry>();
if (string.IsNullOrWhiteSpace(config.Endpoint))
errors.Add(Err("Endpoint", "Endpoint URL is required."));
else if (!Uri.TryCreate(config.Endpoint, UriKind.Absolute, out var uri)
|| (uri.Scheme != "http" && uri.Scheme != "https")
|| string.IsNullOrEmpty(uri.Host))
errors.Add(Err("Endpoint", "Endpoint URL must be a valid http:// or https:// URI."));
if (string.IsNullOrWhiteSpace(config.ApiKey))
errors.Add(Err("ApiKey", "API key is required."));
if (config.ReadTimeoutMs <= 0)
errors.Add(Err("ReadTimeoutMs", "Must be > 0."));
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.FromErrors(errors.ToArray());
ValidationEntry Err(string field, string message) =>
ValidationEntry.Error(
ValidationCategory.ConnectionConfig,
message,
entityName: $"{fieldPrefix}{field}");
}
}
@@ -152,10 +152,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers
// DataConnectionActor children (which own the live OPC UA sessions) // DataConnectionActor children (which own the live OPC UA sessions)
// only exist on the singleton's node. The singleton then re-forwards // only exist on the singleton's node. The singleton then re-forwards
// to its own /user/dcl-manager, which DOES have the connection. // to its own /user/dcl-manager, which DOES have the connection.
Receive<BrowseOpcUaNodeCommand>(msg => _deploymentManagerProxy.Forward(msg)); Receive<BrowseNodeCommand>(msg => _deploymentManagerProxy.Forward(msg));
// Test Bindings (interactive design-time read) — same routing rationale // Test Bindings (interactive design-time read) — same routing rationale
// as BrowseOpcUaNodeCommand above: the singleton always lands on the // as BrowseNodeCommand above: the singleton always lands on the
// active site node, which is the node that owns the DataConnectionActor // active site node, which is the node that owns the DataConnectionActor
// children holding the live OPC UA sessions. // children holding the live OPC UA sessions.
Receive<ReadTagValuesCommand>(msg => _deploymentManagerProxy.Forward(msg)); Receive<ReadTagValuesCommand>(msg => _deploymentManagerProxy.Forward(msg));
@@ -360,13 +360,13 @@ public class CommunicationService
/// <param name="command">The OPC UA browse command.</param> /// <param name="command">The OPC UA browse command.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The browse result (children + truncation flag + structured failure).</returns> /// <returns>The browse result (children + truncation flag + structured failure).</returns>
public Task<BrowseOpcUaNodeResult> BrowseOpcUaNodeAsync( public Task<BrowseNodeResult> BrowseNodeAsync(
string siteId, string siteId,
BrowseOpcUaNodeCommand command, BrowseNodeCommand command,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var envelope = new SiteEnvelope(siteId, command); var envelope = new SiteEnvelope(siteId, command);
return GetActor().Ask<BrowseOpcUaNodeResult>( return GetActor().Ask<BrowseNodeResult>(
envelope, _options.QueryTimeout, cancellationToken); envelope, _options.QueryTimeout, cancellationToken);
} }
@@ -377,7 +377,7 @@ public class CommunicationService
/// server backing the given data connection. Used by the CentralUI "Test /// server backing the given data connection. Used by the CentralUI "Test
/// Bindings" dialog on the Configure Instance page. The Ask is bounded by /// Bindings" dialog on the Configure Instance page. The Ask is bounded by
/// <see cref="CommunicationOptions.QueryTimeout"/> — same latency budget /// <see cref="CommunicationOptions.QueryTimeout"/> — same latency budget
/// as <see cref="BrowseOpcUaNodeAsync"/> (both are interactive one-shot /// as <see cref="BrowseNodeAsync"/> (both are interactive one-shot
/// design-time queries). /// design-time queries).
/// </summary> /// </summary>
/// <param name="siteId">The target site identifier.</param> /// <param name="siteId">The target site identifier.</param>
@@ -234,7 +234,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll. // apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc); HandleSubscribeCompleted(sc);
break; break;
case BrowseOpcUaNodeCommand browse: case BrowseNodeCommand browse:
// Browse is an interactive design-time query; never stash. The // Browse is an interactive design-time query; never stash. The
// adapter has no session yet in this state, so reply with a // adapter has no session yet in this state, so reply with a
// typed ConnectionNotConnected failure so the dialog can render // typed ConnectionNotConnected failure so the dialog can render
@@ -307,7 +307,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case RetryTagResolution: case RetryTagResolution:
HandleRetryTagResolution(); HandleRetryTagResolution();
break; break;
case BrowseOpcUaNodeCommand browse: case BrowseNodeCommand browse:
HandleBrowse(browse); HandleBrowse(browse);
break; break;
case ReadTagValuesCommand read: case ReadTagValuesCommand read:
@@ -432,7 +432,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// apply it so its state survives into the next ReSubscribeAll. // apply it so its state survives into the next ReSubscribeAll.
HandleSubscribeCompleted(sc); HandleSubscribeCompleted(sc);
break; break;
case BrowseOpcUaNodeCommand browse: case BrowseNodeCommand browse:
// Browse is design-time and never stashed. While reconnecting // Browse is design-time and never stashed. While reconnecting
// the adapter has no live session, so the adapter call will // the adapter has no live session, so the adapter call will
// throw ConnectionNotConnectedException — mapped by HandleBrowse. // throw ConnectionNotConnectedException — mapped by HandleBrowse.
@@ -982,7 +982,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// ── OPC UA Tag Browser (interactive design-time query) ── // ── OPC UA Tag Browser (interactive design-time query) ──
/// <summary> /// <summary>
/// Handles a <see cref="BrowseOpcUaNodeCommand"/> forwarded by the /// Handles a <see cref="BrowseNodeCommand"/> forwarded by the
/// <see cref="DataConnectionManagerActor"/>. The capability check (does /// <see cref="DataConnectionManagerActor"/>. The capability check (does
/// this adapter support browsing?) and all browse-failure mapping live /// this adapter support browsing?) and all browse-failure mapping live
/// here because the adapter is held by this actor, not the manager. /// here because the adapter is held by this actor, not the manager.
@@ -999,14 +999,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
/// <see cref="HandleWrite"/> — so the captured <see cref="Sender"/> is /// <see cref="HandleWrite"/> — so the captured <see cref="Sender"/> is
/// safe to use from the continuation (which runs off the actor thread). /// safe to use from the continuation (which runs off the actor thread).
/// </summary> /// </summary>
private void HandleBrowse(BrowseOpcUaNodeCommand command) private void HandleBrowse(BrowseNodeCommand command)
{ {
var sender = Sender; var sender = Sender;
if (_adapter is not IBrowsableDataConnection browsable) if (_adapter is not IBrowsableDataConnection browsable)
{ {
_log.Debug("[{0}] Browse requested but adapter does not implement IBrowsableDataConnection", _connectionName); _log.Debug("[{0}] Browse requested but adapter does not implement IBrowsableDataConnection", _connectionName);
sender.Tell(new BrowseOpcUaNodeResult( sender.Tell(new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure( new BrowseFailure(
@@ -1021,21 +1021,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
{ {
if (t.IsCompletedSuccessfully) if (t.IsCompletedSuccessfully)
{ {
return new BrowseOpcUaNodeResult(t.Result.Children, t.Result.Truncated, Failure: null); return new BrowseNodeResult(t.Result.Children, t.Result.Truncated, Failure: null);
} }
var baseEx = t.Exception?.GetBaseException(); var baseEx = t.Exception?.GetBaseException();
return baseEx switch return baseEx switch
{ {
ConnectionNotConnectedException notConnected => new BrowseOpcUaNodeResult( ConnectionNotConnectedException notConnected => new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)), new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)),
OperationCanceledException => new BrowseOpcUaNodeResult( OperationCanceledException => new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")), new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")),
_ => new BrowseOpcUaNodeResult( _ => new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure( new BrowseFailure(
@@ -46,7 +46,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<WriteTagRequest>(HandleRouteWrite); Receive<WriteTagRequest>(HandleRouteWrite);
Receive<RemoveConnectionCommand>(HandleRemoveConnection); Receive<RemoveConnectionCommand>(HandleRemoveConnection);
Receive<GetAllHealthReports>(HandleGetAllHealthReports); Receive<GetAllHealthReports>(HandleGetAllHealthReports);
Receive<BrowseOpcUaNodeCommand>(HandleBrowse); Receive<BrowseNodeCommand>(HandleBrowse);
Receive<ReadTagValuesCommand>(HandleReadTagValues); Receive<ReadTagValuesCommand>(HandleReadTagValues);
} }
@@ -115,7 +115,7 @@ public class DataConnectionManagerActor : ReceiveActor
} }
/// <summary> /// <summary>
/// Routes a <see cref="BrowseOpcUaNodeCommand"/> from the central UI's OPC UA /// Routes a <see cref="BrowseNodeCommand"/> from the central UI's OPC UA
/// Tag Browser to the child <see cref="DataConnectionActor"/> that owns the /// Tag Browser to the child <see cref="DataConnectionActor"/> that owns the
/// named connection. The manager is the only actor that knows whether a /// named connection. The manager is the only actor that knows whether a
/// connection exists at this site — so it owns the /// connection exists at this site — so it owns the
@@ -123,7 +123,7 @@ public class DataConnectionManagerActor : ReceiveActor
/// else (capability check, session state, server errors) lives inside the /// else (capability check, session state, server errors) lives inside the
/// child where the adapter is held. /// child where the adapter is held.
/// </summary> /// </summary>
private void HandleBrowse(BrowseOpcUaNodeCommand command) private void HandleBrowse(BrowseNodeCommand command)
{ {
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
{ {
@@ -132,7 +132,7 @@ public class DataConnectionManagerActor : ReceiveActor
else else
{ {
_log.Warning("No connection actor for {0} during browse", command.ConnectionName); _log.Warning("No connection actor for {0} during browse", command.ConnectionName);
Sender.Tell(new BrowseOpcUaNodeResult( Sender.Tell(new BrowseNodeResult(
Array.Empty<BrowseNode>(), Array.Empty<BrowseNode>(),
Truncated: false, Truncated: false,
new BrowseFailure( new BrowseFailure(
@@ -0,0 +1,79 @@
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>.
/// </summary>
public interface IMxGatewayClient : IAsyncDisposable
{
/// <summary>Opens the gateway session and registers the client (Register → serverHandle held internally).</summary>
/// <param name="options">Resolved connection parameters.</param>
/// <param name="ct">Cancellation token.</param>
Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default);
/// <summary>Closes the session.</summary>
/// <param name="ct">Cancellation token.</param>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>AddItem + Advise; returns the gateway item handle (as a string subscription id).</summary>
/// <param name="tagPath">Tag address to subscribe to.</param>
/// <param name="ct">Cancellation token.</param>
Task<string> SubscribeAsync(string tagPath, CancellationToken ct = default);
/// <summary>UnAdvise + RemoveItem for a previously returned subscription id.</summary>
/// <param name="subscriptionId">Subscription id returned by <see cref="SubscribeAsync"/>.</param>
/// <param name="ct">Cancellation token.</param>
Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default);
/// <summary>Snapshot read of one or more tags (ReadBulk).</summary>
/// <param name="tagPaths">Tag addresses to read.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tagPaths, CancellationToken ct = default);
/// <summary>Write one or more tag/value pairs (WriteBulk with the configured WriteUserId).</summary>
/// <param name="writes">Tag/value pairs to write.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default);
/// <summary>One Galaxy browse level (BrowseChildren). <paramref name="parentNodeId"/> null → root.</summary>
/// <param name="parentNodeId">Parent node id (Galaxy contained path), or null for root.</param>
/// <param name="ct">Cancellation token.</param>
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>
/// <param name="onUpdate">Callback invoked per advised-tag value change.</param>
/// <param name="ct">Cancellation token; ends the loop when cancelled.</param>
Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default);
}
/// <summary>Builds <see cref="IMxGatewayClient"/> instances.</summary>
public interface IMxGatewayClientFactory
{
/// <summary>Creates a new, unconnected client instance.</summary>
IMxGatewayClient Create();
}
@@ -0,0 +1,220 @@
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;
/// <summary>
/// MxGateway adapter implementing <see cref="IDataConnection"/> + <see cref="IBrowsableDataConnection"/>.
/// Maps IDataConnection concepts onto the MxAccess Gateway session model via the
/// <see cref="IMxGatewayClient"/> seam:
/// <list type="bullet">
/// <item>Connect → OpenSession + Register, then a background event loop.</item>
/// <item>Subscribe → AddItem + Advise; value changes arrive on the event stream.</item>
/// <item>Read/Write → ReadBulk / WriteBulk.</item>
/// <item>Browse → Galaxy repository BrowseChildren.</item>
/// </list>
/// Reconnection is driven by the <c>DataConnectionActor</c>: a stream fault raises
/// <see cref="Disconnected"/>, the actor disposes this adapter, creates a fresh one,
/// reconnects and re-subscribes all tags.
/// </summary>
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,
// plus tagPath → subscriptionId for reverse lookup. Concurrent because the event
// loop reads from a background thread while Subscribe/Unsubscribe mutate.
private readonly ConcurrentDictionary<string, (string TagPath, SubscriptionCallback Callback)> _subs = new();
private readonly ConcurrentDictionary<string, string> _tagToSub = new();
// DataConnectionLayer mirror of OpcUaDataConnection's once-only guard: an int toggled
// with Interlocked.Exchange so only the first caller raises Disconnected.
// 0 = not fired, 1 = fired. Reset on (re)connect.
private int _disconnectFired;
/// <summary>Initializes a new instance of <see cref="MxGatewayDataConnection"/>.</summary>
/// <param name="clientFactory">Factory used to create gateway client instances.</param>
/// <param name="logger">Logger instance.</param>
public MxGatewayDataConnection(IMxGatewayClientFactory clientFactory, ILogger<MxGatewayDataConnection> logger)
{
_clientFactory = clientFactory;
_logger = logger;
}
/// <inheritdoc />
public ConnectionHealth Status => _status;
/// <summary>Raised once when the gateway event stream faults (connection lost).</summary>
public event Action? Disconnected;
/// <inheritdoc />
public async Task ConnectAsync(IDictionary<string, string> connectionDetails, CancellationToken cancellationToken = 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), cancellationToken);
_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 (DisconnectAsync / DisposeAsync cancelled the loop).
}
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();
}
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
_eventLoopCts?.Cancel();
if (_client is not null)
await _client.DisconnectAsync(cancellationToken);
_status = ConnectionHealth.Disconnected;
}
/// <inheritdoc />
public async Task<string> SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default)
{
var subId = await _client!.SubscribeAsync(tagPath, cancellationToken);
_subs[subId] = (tagPath, callback);
_tagToSub[tagPath] = subId;
return subId;
}
/// <inheritdoc />
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
if (_subs.TryRemove(subscriptionId, out var s))
_tagToSub.TryRemove(s.TagPath, out _);
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
}
/// <inheritdoc />
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
{
var r = (await _client!.ReadAsync(new[] { tagPath }, cancellationToken)).Single();
return ToReadResult(r);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, ReadResult>> ReadBatchAsync(IEnumerable<string> tagPaths, CancellationToken cancellationToken = default)
{
var list = tagPaths.ToList();
var results = await _client!.ReadAsync(list, cancellationToken);
return results.ToDictionary(r => r.TagPath, ToReadResult);
}
private static ReadResult ToReadResult(MxReadOutcome r) => r.Success
? new ReadResult(true, new TagValue(r.Value, r.Quality, r.Timestamp), null)
: new ReadResult(false, null, r.Error);
/// <inheritdoc />
public async Task<WriteResult> WriteAsync(string tagPath, object? value, CancellationToken cancellationToken = default)
{
var w = (await _client!.WriteAsync(new[] { (tagPath, value) }, cancellationToken)).Single();
return new WriteResult(w.Success, w.Error);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, WriteResult>> WriteBatchAsync(IDictionary<string, object?> values, CancellationToken cancellationToken = default)
{
var results = await _client!.WriteAsync(values.Select(kv => (kv.Key, kv.Value)).ToList(), cancellationToken);
return results.ToDictionary(w => w.TagPath, w => new WriteResult(w.Success, w.Error));
}
/// <inheritdoc />
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object?> values, string flagPath, object? flagValue,
string responsePath, object? responseValue, TimeSpan timeout, CancellationToken cancellationToken = default)
{
await WriteBatchAsync(values, cancellationToken);
await WriteAsync(flagPath, flagValue, cancellationToken);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
try
{
while (!timeoutCts.IsCancellationRequested)
{
var r = await ReadAsync(responsePath, timeoutCts.Token);
// r.Value is a TagValue wrapper; compare its underlying scalar. String
// projection tolerates numeric type differences across the gRPC boundary.
if (r.Success && string.Equals(r.Value?.Value?.ToString(), responseValue?.ToString(), StringComparison.Ordinal))
return true;
await Task.Delay(TimeSpan.FromMilliseconds(200), timeoutCts.Token);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Timeout elapsed (the linked CTS, not the caller's token) — fall through to false.
}
return false;
}
/// <inheritdoc />
public async Task<BrowseChildrenResult> BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default)
{
if (_status != ConnectionHealth.Connected || _client is null)
throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");
var (children, truncated) = await _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
var nodes = children
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
.ToList();
return new BrowseChildrenResult(nodes, truncated);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
_eventLoopCts?.Cancel();
if (_client is not null)
await _client.DisposeAsync();
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,267 @@
using System.Collections.Concurrent;
using System.Globalization;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <summary>
/// Production <see cref="IMxGatewayClient"/> implementation over the
/// <c>ZB.MOM.WW.MxGateway.Client</c> NuGet package. This is the only type in the
/// Data Connection Layer that references the generated gRPC/protobuf contracts;
/// the adapter and its tests run entirely against the neutral seam.
/// </summary>
public sealed class RealMxGatewayClient : IMxGatewayClient
{
private readonly ILogger<RealMxGatewayClient> _logger;
private readonly ILoggerFactory? _loggerFactory;
private MxGatewayClient? _client;
private GalaxyRepositoryClient? _galaxy;
private MxGatewaySession? _session;
private int _serverHandle;
private int _writeUserId;
private int _readTimeoutMs;
private ulong _lastSeq;
// tag ↔ MXAccess item handle, maintained across subscribe/write.
private readonly ConcurrentDictionary<string, int> _tagToHandle = new();
private readonly ConcurrentDictionary<int, string> _handleToTag = new();
/// <summary>Initializes a new instance of <see cref="RealMxGatewayClient"/>.</summary>
/// <param name="loggerFactory">Logger factory shared with the gateway client.</param>
public RealMxGatewayClient(ILoggerFactory? loggerFactory)
{
_loggerFactory = loggerFactory;
_logger = (loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance)
.CreateLogger<RealMxGatewayClient>();
}
/// <inheritdoc />
public async Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default)
{
_writeUserId = options.WriteUserId;
_readTimeoutMs = options.ReadTimeoutMs;
var clientOptions = new MxGatewayClientOptions
{
Endpoint = new Uri(options.Endpoint),
ApiKey = options.ApiKey,
UseTls = options.UseTls,
CaCertificatePath = options.CaFile,
ServerNameOverride = options.ServerName,
LoggerFactory = _loggerFactory,
};
_client = MxGatewayClient.Create(clientOptions);
_galaxy = GalaxyRepositoryClient.Create(clientOptions);
_session = await _client.OpenSessionAsync(cancellationToken: ct).ConfigureAwait(false);
_serverHandle = await _session.RegisterAsync(options.ClientName, ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task DisconnectAsync(CancellationToken ct = default)
{
if (_session is not null)
await _session.CloseAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<string> SubscribeAsync(string tagPath, CancellationToken ct = default)
{
var handle = await GetOrAddItemHandleAsync(tagPath, ct).ConfigureAwait(false);
await _session!.AdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false);
return handle.ToString(CultureInfo.InvariantCulture);
}
/// <inheritdoc />
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default)
{
if (!int.TryParse(subscriptionId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var handle))
return;
await _session!.UnAdviseAsync(_serverHandle, handle, ct).ConfigureAwait(false);
await _session.RemoveItemAsync(_serverHandle, handle, ct).ConfigureAwait(false);
if (_handleToTag.TryRemove(handle, out var tag))
_tagToHandle.TryRemove(tag, out _);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MxReadOutcome>> ReadAsync(IReadOnlyList<string> tagPaths, CancellationToken ct = default)
{
var results = await _session!
.ReadBulkAsync(_serverHandle, tagPaths, TimeSpan.FromMilliseconds(_readTimeoutMs), ct)
.ConfigureAwait(false);
return results.Select(r => new MxReadOutcome(
r.TagAddress,
r.WasSuccessful,
r.WasSuccessful ? r.Value?.ToClrValue() : null,
MapQuality(r.Quality, r.Statuses),
r.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
r.WasSuccessful ? null : r.ErrorMessage)).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<MxWriteOutcome>> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default)
{
// Build entries in request order; remember the tag for each handle so the
// per-handle BulkWriteResult can be mapped back to its tag.
var entries = new List<WriteBulkEntry>(writes.Count);
var orderedTags = new List<string>(writes.Count);
foreach (var (tag, value) in writes)
{
var handle = await GetOrAddItemHandleAsync(tag, ct).ConfigureAwait(false);
entries.Add(new WriteBulkEntry
{
ItemHandle = handle,
Value = ToMxValue(value),
UserId = _writeUserId,
});
orderedTags.Add(tag);
}
var results = await _session!.WriteBulkAsync(_serverHandle, entries, ct).ConfigureAwait(false);
// Results are returned in request order; pair by index back to the tags.
return results.Select((r, i) => new MxWriteOutcome(
i < orderedTags.Count ? orderedTags[i] : (_handleToTag.TryGetValue(r.ItemHandle, out var t) ? t : ""),
r.WasSuccessful,
r.WasSuccessful ? null : r.ErrorMessage)).ToList();
}
/// <inheritdoc />
public async Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default)
{
var request = new BrowseChildrenRequest { IncludeAttributes = true };
// Object NodeIds are the Galaxy gobject id (encoded as a string); attribute
// NodeIds are FullTagReference leaves and never arrive here as a parent.
if (!string.IsNullOrEmpty(parentNodeId)
&& int.TryParse(parentNodeId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var gobjectId))
{
request.ParentGobjectId = gobjectId;
}
BrowseChildrenReply reply;
try
{
reply = await _galaxy!.BrowseChildrenRawAsync(request, ct).ConfigureAwait(false);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
{
throw new ConnectionNotConnectedException($"MxGateway repository unavailable: {ex.Status.Detail}");
}
var children = new List<MxBrowseChild>();
for (var i = 0; i < reply.Children.Count; i++)
{
var obj = reply.Children[i];
var hasChildren = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
// Navigable container node, keyed by gobject id.
children.Add(new MxBrowseChild(
obj.GobjectId.ToString(CultureInfo.InvariantCulture),
string.IsNullOrEmpty(obj.TagName) ? obj.ContainedName : obj.TagName,
BrowseNodeClass.Object,
hasChildren || obj.Attributes.Count > 0));
// Selectable attribute leaves, keyed by their full tag reference.
foreach (var attr in obj.Attributes)
{
children.Add(new MxBrowseChild(
attr.FullTagReference,
attr.AttributeName,
BrowseNodeClass.Variable,
false));
}
}
return (children, !string.IsNullOrEmpty(reply.NextPageToken));
}
/// <inheritdoc />
public async Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default)
{
await foreach (var ev in _session!.StreamEventsAsync(_lastSeq, ct).ConfigureAwait(false))
{
_lastSeq = ev.WorkerSequence;
if (ev.Family != MxEventFamily.OnDataChange)
continue;
if (!_handleToTag.TryGetValue(ev.ItemHandle, out var tag))
continue;
onUpdate(new MxValueUpdate(
tag,
ev.Value?.ToClrValue(),
MapQuality(ev.Quality, ev.Statuses),
ev.SourceTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow));
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_session is not null) await _session.DisposeAsync().ConfigureAwait(false);
if (_client is not null) await _client.DisposeAsync().ConfigureAwait(false);
if (_galaxy is not null) await _galaxy.DisposeAsync().ConfigureAwait(false);
}
private async Task<int> GetOrAddItemHandleAsync(string tagPath, CancellationToken ct)
{
if (_tagToHandle.TryGetValue(tagPath, out var existing))
return existing;
var handle = await _session!.AddItemAsync(_serverHandle, tagPath, ct).ConfigureAwait(false);
_tagToHandle[tagPath] = handle;
_handleToTag[handle] = tagPath;
return handle;
}
/// <summary>
/// Maps MXAccess quality. A failing status proxy is authoritative bad; otherwise
/// the OPC-style quality byte: ≥192 Good, ≥64 Uncertain, else Bad.
/// </summary>
private static QualityCode MapQuality(int quality, IEnumerable<MxStatusProxy> statuses)
{
if (statuses.Any(s => !s.IsSuccess()))
return QualityCode.Bad;
return quality switch
{
>= 192 => QualityCode.Good,
>= 64 => QualityCode.Uncertain,
_ => QualityCode.Bad,
};
}
private static MxValue ToMxValue(object? value) => value switch
{
null => new MxValue { IsNull = true },
bool b => b.ToMxValue(),
int i => i.ToMxValue(),
long l => l.ToMxValue(),
float f => f.ToMxValue(),
double d => d.ToMxValue(),
string s => s.ToMxValue(),
DateTimeOffset dto => dto.ToMxValue(),
DateTime dt => dt.ToMxValue(),
// Fall back to invariant string for any other CLR type.
_ => Convert.ToString(value, CultureInfo.InvariantCulture)!.ToMxValue(),
};
}
/// <summary>Builds <see cref="RealMxGatewayClient"/> instances.</summary>
public sealed class RealMxGatewayClientFactory : IMxGatewayClientFactory
{
private readonly ILoggerFactory? _loggerFactory;
/// <summary>Initializes a new factory.</summary>
/// <param name="loggerFactory">Logger factory passed to each created client.</param>
public RealMxGatewayClientFactory(ILoggerFactory? loggerFactory) => _loggerFactory = loggerFactory;
/// <inheritdoc />
public IMxGatewayClient Create() => new RealMxGatewayClient(_loggerFactory);
}
@@ -38,6 +38,13 @@ public class DataConnectionFactory : IDataConnectionFactory
RegisterAdapter("OpcUa", details => new OpcUaDataConnection( RegisterAdapter("OpcUa", details => new OpcUaDataConnection(
new RealOpcUaClientFactory(globalOptions, _loggerFactory), new RealOpcUaClientFactory(globalOptions, _loggerFactory),
_loggerFactory.CreateLogger<OpcUaDataConnection>())); _loggerFactory.CreateLogger<OpcUaDataConnection>()));
// MxGateway: gRPC to the MxAccess Gateway. The RealMxGatewayClient wraps the
// ZB.MOM.WW.MxGateway.Client package; per-connection config arrives via the
// flat details dict (see MxGatewayEndpointConfigSerializer).
RegisterAdapter("MxGateway", details => new MxGatewayDataConnection(
new RealMxGatewayClientFactory(_loggerFactory),
_loggerFactory.CreateLogger<MxGatewayDataConnection>()));
} }
/// <summary> /// <summary>
@@ -0,0 +1,11 @@
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";
}
@@ -18,6 +18,9 @@ public static class ServiceCollectionExtensions
services.AddOptions<OpcUaGlobalOptions>() services.AddOptions<OpcUaGlobalOptions>()
.BindConfiguration("OpcUa"); .BindConfiguration("OpcUa");
services.AddOptions<MxGatewayGlobalOptions>()
.BindConfiguration("MxGateway");
// WP-34: Register the factory for protocol extensibility // WP-34: Register the factory for protocol extensibility
services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>(); services.AddSingleton<IDataConnectionFactory, DataConnectionFactory>();
@@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" /> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -149,12 +149,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes); Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes);
// OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager. // OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager.
// BrowseOpcUaNodeCommand is routed to this singleton (active node) by // BrowseNodeCommand is routed to this singleton (active node) by
// SiteCommunicationActor so the dcl-manager we forward to is guaranteed // SiteCommunicationActor so the dcl-manager we forward to is guaranteed
// to be the one holding the live DataConnectionActor children. ActorSelection // to be the one holding the live DataConnectionActor children. ActorSelection
// has no Forward() extension in this Akka.NET version, so we Tell with the // has no Forward() extension in this Akka.NET version, so we Tell with the
// original Sender preserved (semantically identical to Forward). // original Sender preserved (semantically identical to Forward).
Receive<BrowseOpcUaNodeCommand>(msg => Receive<BrowseNodeCommand>(msg =>
Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender)); Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
// Test Bindings — same singleton-only re-forward as the browse handler // Test Bindings — same singleton-only re-forward as the browse handler
@@ -767,6 +767,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
return Commons.Serialization.OpcUaEndpointConfigSerializer.ToFlatDict(config); return Commons.Serialization.OpcUaEndpointConfigSerializer.ToFlatDict(config);
} }
if (string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
{
var config = Commons.Serialization.MxGatewayEndpointConfigSerializer.Deserialize(json);
return Commons.Serialization.MxGatewayEndpointConfigSerializer.ToFlatDict(config);
}
// Fallback: assume legacy flat-dict shape for any future / unknown protocol. // Fallback: assume legacy flat-dict shape for any future / unknown protocol.
try try
{ {
@@ -44,12 +44,55 @@ public class DataConnectionFormTests : BunitContext
} }
[Fact] [Fact]
public void NoProtocolDropdown_IsRendered() public void ProtocolDropdown_IsRendered_OnCreate_WithBothProtocols()
{ {
var cut = RenderForCreateSite(1); var cut = RenderForCreateSite(1);
Assert.DoesNotContain("Custom", cut.Markup);
var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList(); var labels = cut.FindAll("label").Select(l => l.TextContent.Trim()).ToList();
Assert.DoesNotContain(labels, l => l == "Protocol"); Assert.Contains(labels, l => l == "Protocol");
// The protocol select offers OPC UA and MxGateway.
var optionTexts = cut.FindAll("option").Select(o => o.TextContent.Trim()).ToList();
Assert.Contains("OPC UA", optionTexts);
Assert.Contains("MxGateway", optionTexts);
}
[Fact]
public async Task Save_MxGateway_PersistsTypedJsonAndProtocolMxGateway()
{
DataConnection? captured = null;
await _siteRepo.AddDataConnectionAsync(
Arg.Do<DataConnection>(d => captured = d));
var cut = RenderForCreateSite(1);
// Switch protocol to MxGateway — re-renders with the MxGateway editor.
cut.FindAll("select")
.First(s => s.QuerySelectorAll("option").Any(o => o.TextContent.Trim() == "MxGateway"))
.Change("MxGateway");
// Name (skip readonly Site plaintext input; MxGateway editor inputs carry placeholders).
cut.FindAll("input[type='text']")
.First(i => !i.HasAttribute("readonly") && i.GetAttribute("placeholder") is null)
.Change("MX-1");
// Gateway endpoint
cut.FindAll("input[type='text']")
.First(i => i.GetAttribute("placeholder")?.StartsWith("http://") == true)
.Change("http://gw:5000");
// API key (password input)
cut.FindAll("input[type='password']")
.First(i => i.GetAttribute("placeholder")?.Contains("API key") == true)
.Change("secret");
await cut.FindAll("button")
.First(b => b.TextContent.Trim() == "Save").ClickAsync(new());
Assert.NotNull(captured);
Assert.Equal("MxGateway", captured!.Protocol);
Assert.NotNull(captured.PrimaryConfiguration);
using var doc = JsonDocument.Parse(captured.PrimaryConfiguration!);
Assert.Equal("http://gw:5000",
doc.RootElement.GetProperty("endpoint").GetString());
} }
[Fact] [Fact]
@@ -44,10 +44,10 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For<IAuditService>())); Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For<IAuditService>()));
Services.AddSingleton(Substitute.For<IFlatteningPipeline>()); Services.AddSingleton(Substitute.For<IFlatteningPipeline>());
// The page renders <OpcUaBrowserDialog/> and <TestBindingsDialog/> at // The page renders <NodeBrowserDialog/> and <TestBindingsDialog/> at
// the bottom; their @inject directives need a registered service even // the bottom; their @inject directives need a registered service even
// though this test doesn't open either dialog. // though this test doesn't open either dialog.
Services.AddSingleton(Substitute.For<IOpcUaBrowseService>()); Services.AddSingleton(Substitute.For<IBrowseService>());
Services.AddSingleton(Substitute.For<IBindingTester>()); Services.AddSingleton(Substitute.For<IBindingTester>());
// Auth: a system-wide Deployment user so SiteScope grants everything. // Auth: a system-wide Deployment user so SiteScope grants everything.
@@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
/// <summary> /// <summary>
/// Verifies that <see cref="BrowseOpcUaNodeCommand"/> is discovered by /// Verifies that <see cref="BrowseNodeCommand"/> is discovered by
/// <see cref="ManagementCommandRegistry"/> so it travels over the management /// <see cref="ManagementCommandRegistry"/> so it travels over the management
/// boundary as a known command (resolvable by wire name and round-trippable /// boundary as a known command (resolvable by wire name and round-trippable
/// through <c>GetCommandName</c> / <c>Resolve</c>). /// through <c>GetCommandName</c> / <c>Resolve</c>).
@@ -11,13 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages;
public class BrowseCommandsRegistryTests public class BrowseCommandsRegistryTests
{ {
[Fact] [Fact]
public void Registry_discovers_BrowseOpcUaNodeCommand() public void Registry_discovers_BrowseNodeCommand()
{ {
// GetCommandName throws ArgumentException for any type the registry // GetCommandName throws ArgumentException for any type the registry
// does not contain, so a successful call here is proof of discovery. // does not contain, so a successful call here is proof of discovery.
var name = ManagementCommandRegistry.GetCommandName(typeof(BrowseOpcUaNodeCommand)); var name = ManagementCommandRegistry.GetCommandName(typeof(BrowseNodeCommand));
Assert.Equal("BrowseOpcUaNode", name); Assert.Equal("BrowseNode", name);
Assert.Equal(typeof(BrowseOpcUaNodeCommand), ManagementCommandRegistry.Resolve(name)); Assert.Equal(typeof(BrowseNodeCommand), ManagementCommandRegistry.Resolve(name));
} }
} }
@@ -0,0 +1,84 @@
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types.DataConnections;
public class MxGatewayEndpointConfigSerializerTests
{
[Fact]
public void Serialize_then_Deserialize_round_trips_all_fields()
{
var original = new MxGatewayEndpointConfig
{
Endpoint = "https://gw:5001",
ApiKey = "secret-key",
ClientName = "client-a",
WriteUserId = 7,
UseTls = true,
CaFile = "/certs/ca.pem",
ServerName = "gw.local",
ReadTimeoutMs = 1234
};
var json = MxGatewayEndpointConfigSerializer.Serialize(original);
var round = MxGatewayEndpointConfigSerializer.Deserialize(json);
Assert.Equal(original.Endpoint, round.Endpoint);
Assert.Equal(original.ApiKey, round.ApiKey);
Assert.Equal(original.ClientName, round.ClientName);
Assert.Equal(original.WriteUserId, round.WriteUserId);
Assert.Equal(original.UseTls, round.UseTls);
Assert.Equal(original.CaFile, round.CaFile);
Assert.Equal(original.ServerName, round.ServerName);
Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{ not valid json")]
public void Deserialize_null_blank_or_malformed_returns_default(string? json)
{
var def = new MxGatewayEndpointConfig();
var result = MxGatewayEndpointConfigSerializer.Deserialize(json);
Assert.Equal(def.Endpoint, result.Endpoint);
Assert.Equal(def.ReadTimeoutMs, result.ReadTimeoutMs);
}
[Fact]
public void ToFlatDict_FromFlatDict_round_trips()
{
var original = new MxGatewayEndpointConfig
{
Endpoint = "http://x:5000",
ApiKey = "k",
ClientName = "c",
WriteUserId = 3,
UseTls = true,
CaFile = "/ca",
ServerName = "s",
ReadTimeoutMs = 999
};
var dict = MxGatewayEndpointConfigSerializer.ToFlatDict(original);
var round = MxGatewayEndpointConfigSerializer.FromFlatDict(dict);
Assert.Equal(original.Endpoint, round.Endpoint);
Assert.Equal(original.ApiKey, round.ApiKey);
Assert.Equal(original.ClientName, round.ClientName);
Assert.Equal(original.WriteUserId, round.WriteUserId);
Assert.Equal(original.UseTls, round.UseTls);
Assert.Equal(original.CaFile, round.CaFile);
Assert.Equal(original.ServerName, round.ServerName);
Assert.Equal(original.ReadTimeoutMs, round.ReadTimeoutMs);
}
[Fact]
public void FromFlatDict_invalid_numeric_falls_back_to_default()
{
var back = MxGatewayEndpointConfigSerializer.FromFlatDict(
new Dictionary<string, string> { ["ReadTimeoutMs"] = "not-a-number" });
Assert.Equal(new MxGatewayEndpointConfig().ReadTimeoutMs, back.ReadTimeoutMs);
}
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Validators;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Validators;
public class MxGatewayEndpointConfigValidatorTests
{
private static MxGatewayEndpointConfig Valid() => new()
{
Endpoint = "http://gw:5000",
ApiKey = "key",
};
[Fact]
public void Validate_ValidConfig_IsValid()
{
var result = MxGatewayEndpointConfigValidator.Validate(Valid());
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public void Validate_MissingEndpoint_Fails()
{
var c = Valid();
c.Endpoint = "";
var r = MxGatewayEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e =>
e.EntityName == "Endpoint"
&& e.Category == ValidationCategory.ConnectionConfig
&& e.Message.Contains("required", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("opc.tcp://x:4840")]
[InlineData("ftp://x")]
[InlineData("not a url")]
public void Validate_BadEndpointScheme_Fails(string url)
{
var c = Valid();
c.Endpoint = url;
var r = MxGatewayEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e => e.EntityName == "Endpoint");
}
[Fact]
public void Validate_MissingApiKey_Fails()
{
var c = Valid();
c.ApiKey = "";
var r = MxGatewayEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e => e.EntityName == "ApiKey");
}
[Fact]
public void Validate_NonPositiveReadTimeout_Fails()
{
var c = Valid();
c.ReadTimeoutMs = 0;
var r = MxGatewayEndpointConfigValidator.Validate(c);
Assert.False(r.IsValid);
Assert.Contains(r.Errors, e => e.EntityName == "ReadTimeoutMs");
}
[Fact]
public void Validate_PrefixedFieldNames_AppearInErrors()
{
var c = Valid();
c.Endpoint = "";
var r = MxGatewayEndpointConfigValidator.Validate(c, "Primary.");
Assert.Contains(r.Errors, e => e.EntityName == "Primary.Endpoint");
}
}
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors;
/// Task 10 (opcua-tag-browser): the site-side /// Task 10 (opcua-tag-browser): the site-side
/// <see cref="DataConnectionManagerActor"/> + child /// <see cref="DataConnectionManagerActor"/> + child
/// <see cref="DataConnectionActor"/> together resolve /// <see cref="DataConnectionActor"/> together resolve
/// <see cref="BrowseOpcUaNodeCommand"/> against the live adapter and surface /// <see cref="BrowseNodeCommand"/> against the live adapter and surface
/// every browse outcome as a typed <see cref="BrowseFailure"/>. The split is: /// every browse outcome as a typed <see cref="BrowseFailure"/>. The split is:
/// the manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it /// the manager owns <see cref="BrowseFailureKind.ConnectionNotFound"/> (only it
/// knows the per-site connection set); everything else lives in the child where /// knows the per-site connection set); everything else lives in the child where
@@ -50,9 +50,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
// No CreateConnectionCommand sent — the manager has zero children, so a // No CreateConnectionCommand sent — the manager has zero children, so a
// browse against any name must be rejected with ConnectionNotFound // browse against any name must be rejected with ConnectionNotFound
// (the manager is the only actor with site-level visibility). // (the manager is the only actor with site-level visibility).
manager.Tell(new BrowseOpcUaNodeCommand("unknown-connection", ParentNodeId: null)); manager.Tell(new BrowseNodeCommand("unknown-connection", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>(); var reply = ExpectMsg<BrowseNodeResult>();
Assert.NotNull(reply.Failure); Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind); Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind);
Assert.Empty(reply.Children); Assert.Empty(reply.Children);
@@ -80,9 +80,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2)); TimeSpan.FromSeconds(2));
manager.Tell(new BrowseOpcUaNodeCommand("conn-bare", ParentNodeId: null)); manager.Tell(new BrowseNodeCommand("conn-bare", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3)); var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure); Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind); Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind);
Assert.Empty(reply.Children); Assert.Empty(reply.Children);
@@ -120,9 +120,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2)); TimeSpan.FromSeconds(2));
manager.Tell(new BrowseOpcUaNodeCommand("conn-ok", ParentNodeId: null)); manager.Tell(new BrowseNodeCommand("conn-ok", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3)); var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.Null(reply.Failure); Assert.Null(reply.Failure);
Assert.Equal(2, reply.Children.Count); Assert.Equal(2, reply.Children.Count);
Assert.Equal("ns=2;s=A", reply.Children[0].NodeId); Assert.Equal("ns=2;s=A", reply.Children[0].NodeId);
@@ -155,9 +155,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
() => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"),
TimeSpan.FromSeconds(2)); TimeSpan.FromSeconds(2));
manager.Tell(new BrowseOpcUaNodeCommand("conn-down", ParentNodeId: null)); manager.Tell(new BrowseNodeCommand("conn-down", ParentNodeId: null));
var reply = ExpectMsg<BrowseOpcUaNodeResult>(TimeSpan.FromSeconds(3)); var reply = ExpectMsg<BrowseNodeResult>(TimeSpan.FromSeconds(3));
Assert.NotNull(reply.Failure); Assert.NotNull(reply.Failure);
Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind); Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind);
Assert.Empty(reply.Children); Assert.Empty(reply.Children);
@@ -0,0 +1,65 @@
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// In-memory fake <see cref="IMxGatewayClient"/> for adapter unit tests. Lets tests
/// drive the event loop (push updates / fault the stream) and stub read/write/browse.
/// </summary>
public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFactory
{
public MxGatewayConnectionOptions? ConnectedWith;
public readonly List<string> Subscribed = new();
public readonly List<string> Unsubscribed = new();
public readonly TaskCompletionSource EventLoopGate = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Action<MxValueUpdate>? OnUpdate;
public Func<IReadOnlyList<string>, IReadOnlyList<MxReadOutcome>>? ReadHandler;
public Func<IReadOnlyList<(string TagPath, object? Value)>, 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)
{
var id = (++_nextHandle).ToString();
Subscribed.Add(tag);
return Task.FromResult(id);
}
public Task UnsubscribeAsync(string id, CancellationToken ct = default)
{
Unsubscribed.Add(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 TagPath, object? Value)> w, CancellationToken ct = default)
=> Task.FromResult(WriteHandler!(w));
public Task<(IReadOnlyList<MxBrowseChild> Children, bool Truncated)> 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 FaultEventLoop() faults it to simulate a stream break
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>Simulate a stream break so the adapter raises Disconnected.</summary>
public void FaultEventLoop() => EventLoopGate.TrySetException(new Exception("stream broke"));
}
@@ -0,0 +1,259 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
public class MxGatewayDataConnectionTests
{
private static MxGatewayDataConnection NewAdapter(FakeMxGatewayClient fake) =>
new(fake, NullLogger<MxGatewayDataConnection>.Instance);
private static Dictionary<string, string> Details(int writeUserId = 0) => new()
{
["Endpoint"] = "http://gw:5000",
["ApiKey"] = "key",
["ClientName"] = "client-a",
["WriteUserId"] = writeUserId.ToString(),
["ReadTimeoutMs"] = "2000",
};
// ── Task 6: connect / status / Disconnected ──
[Fact]
public async Task ConnectAsync_resolves_options_and_sets_status_connected()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details(writeUserId: 7));
Assert.Equal(ConnectionHealth.Connected, adapter.Status);
Assert.NotNull(fake.ConnectedWith);
Assert.Equal("http://gw:5000", fake.ConnectedWith!.Endpoint);
Assert.Equal("client-a", fake.ConnectedWith.ClientName);
Assert.Equal(7, fake.ConnectedWith.WriteUserId);
}
[Fact]
public async Task ConnectAsync_blank_client_name_defaults_to_scadabridge()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
var details = Details();
details["ClientName"] = "";
await adapter.ConnectAsync(details);
Assert.Equal("scadabridge", fake.ConnectedWith!.ClientName);
}
[Fact]
public async Task Disconnected_fires_exactly_once_when_event_loop_faults()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
int raised = 0;
adapter.Disconnected += () => Interlocked.Increment(ref raised);
await adapter.ConnectAsync(Details());
// Wait for the event loop to attach.
await WaitUntil(() => fake.OnUpdate is not null);
fake.FaultEventLoop();
await WaitUntil(() => raised >= 1);
Assert.Equal(1, raised);
Assert.Equal(ConnectionHealth.Disconnected, adapter.Status);
}
// ── Task 7: subscribe / unsubscribe + event routing ──
[Fact]
public async Task Subscribed_tag_update_invokes_callback_with_mapped_TagValue()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
await WaitUntil(() => fake.OnUpdate is not null);
TagValue? received = null;
await adapter.SubscribeAsync("Area.Pump.Speed", (_, v) => received = v);
Assert.Contains("Area.Pump.Speed", fake.Subscribed);
var ts = DateTimeOffset.UtcNow;
fake.OnUpdate!(new MxValueUpdate("Area.Pump.Speed", 42.0, QualityCode.Good, ts));
Assert.NotNull(received);
Assert.Equal(42.0, received!.Value);
Assert.Equal(QualityCode.Good, received.Quality);
Assert.Equal(ts, received.Timestamp);
}
[Fact]
public async Task Unsubscribe_stops_routing_updates()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
await WaitUntil(() => fake.OnUpdate is not null);
int hits = 0;
var subId = await adapter.SubscribeAsync("T", (_, _) => hits++);
await adapter.UnsubscribeAsync(subId);
fake.OnUpdate!(new MxValueUpdate("T", 1, QualityCode.Good, DateTimeOffset.UtcNow));
Assert.Equal(0, hits);
Assert.Contains(subId, fake.Unsubscribed);
}
// ── Task 8: read / write + error classification ──
[Fact]
public async Task ReadAsync_maps_success_and_failure()
{
var fake = new FakeMxGatewayClient
{
ReadHandler = tags => tags.Select(t => t == "ok"
? new MxReadOutcome(t, true, 5, QualityCode.Good, DateTimeOffset.UtcNow, null)
: new MxReadOutcome(t, false, null, QualityCode.Bad, default, "bad tag")).ToList()
};
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var ok = await adapter.ReadAsync("ok");
Assert.True(ok.Success);
Assert.Equal(5, ok.Value!.Value);
var bad = await adapter.ReadAsync("nope");
Assert.False(bad.Success);
Assert.Equal("bad tag", bad.ErrorMessage);
}
[Fact]
public async Task ReadBatchAsync_returns_dictionary_keyed_by_tag()
{
var fake = new FakeMxGatewayClient
{
ReadHandler = tags => tags.Select(t =>
new MxReadOutcome(t, true, t.Length, QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList()
};
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var results = await adapter.ReadBatchAsync(new[] { "aa", "bbb" });
Assert.Equal(2, results["aa"].Value!.Value);
Assert.Equal(3, results["bbb"].Value!.Value);
}
[Fact]
public async Task WriteAsync_maps_failure_to_unsuccessful_WriteResult()
{
var fake = new FakeMxGatewayClient
{
WriteHandler = writes => writes.Select(w =>
new MxWriteOutcome(w.TagPath, false, "rejected")).ToList()
};
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var r = await adapter.WriteAsync("T", 1);
Assert.False(r.Success);
Assert.Equal("rejected", r.ErrorMessage);
}
// ── Task 9: WriteBatchAndWait ──
[Fact]
public async Task WriteBatchAndWait_returns_true_when_response_matches()
{
var writeCalls = new List<string>();
var fake = new FakeMxGatewayClient
{
WriteHandler = writes =>
{
writeCalls.AddRange(writes.Select(w => w.TagPath));
return writes.Select(w => new MxWriteOutcome(w.TagPath, true, null)).ToList();
},
// Response path already reads the expected value.
ReadHandler = tags => tags.Select(t =>
new MxReadOutcome(t, true, "DONE", QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList()
};
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var ok = await adapter.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["V"] = 1 }, "Flag", 1, "Resp", "DONE",
TimeSpan.FromSeconds(5));
Assert.True(ok);
// Values written before the flag, and the flag itself.
Assert.Contains("V", writeCalls);
Assert.Contains("Flag", writeCalls);
}
[Fact]
public async Task WriteBatchAndWait_returns_false_on_timeout()
{
var fake = new FakeMxGatewayClient
{
WriteHandler = writes => writes.Select(w => new MxWriteOutcome(w.TagPath, true, null)).ToList(),
ReadHandler = tags => tags.Select(t =>
new MxReadOutcome(t, true, "NEVER", QualityCode.Good, DateTimeOffset.UtcNow, null)).ToList()
};
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var ok = await adapter.WriteBatchAndWaitAsync(
new Dictionary<string, object?> { ["V"] = 1 }, "Flag", 1, "Resp", "DONE",
TimeSpan.FromMilliseconds(300));
Assert.False(ok);
}
// ── Task 10: browse ──
[Fact]
public async Task BrowseChildrenAsync_maps_children_and_truncated()
{
var fake = new FakeMxGatewayClient
{
BrowseHandler = _ => (new List<MxBrowseChild>
{
new("Area1", "Area1", BrowseNodeClass.Object, true),
new("Area1.Pump.Speed", "Speed", BrowseNodeClass.Variable, false),
}, true)
};
var adapter = NewAdapter(fake);
await adapter.ConnectAsync(Details());
var result = await adapter.BrowseChildrenAsync(null);
Assert.True(result.Truncated);
Assert.Equal(2, result.Children.Count);
Assert.Equal(BrowseNodeClass.Object, result.Children[0].NodeClass);
Assert.True(result.Children[0].HasChildren);
Assert.Equal("Area1.Pump.Speed", result.Children[1].NodeId);
Assert.Equal(BrowseNodeClass.Variable, result.Children[1].NodeClass);
}
[Fact]
public async Task BrowseChildrenAsync_throws_when_not_connected()
{
var fake = new FakeMxGatewayClient();
var adapter = NewAdapter(fake);
// Not connected.
await Assert.ThrowsAsync<ConnectionNotConnectedException>(
() => adapter.BrowseChildrenAsync(null));
}
private static async Task WaitUntil(Func<bool> condition, int timeoutMs = 2000)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (!condition() && sw.ElapsedMilliseconds < timeoutMs)
await Task.Delay(10);
Assert.True(condition(), "Condition not met within timeout.");
}
}
@@ -18,6 +18,16 @@ public class DataConnectionFactoryTests
Assert.IsType<OpcUaDataConnection>(connection); Assert.IsType<OpcUaDataConnection>(connection);
} }
[Fact]
public void Create_MxGateway_ReturnsMxGatewayAdapter()
{
var factory = new DataConnectionFactory(NullLoggerFactory.Instance);
var connection = factory.Create("MxGateway", new Dictionary<string, string>());
Assert.IsType<MxGatewayDataConnection>(connection);
}
[Fact] [Fact]
public void Create_CaseInsensitive() public void Create_CaseInsensitive()
{ {