diff --git a/Directory.Packages.props b/Directory.Packages.props index df8ad1a6..d2ef347e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,6 +73,8 @@ to mark tests as Skipped (not silently Passed) when MSSQL is unreachable. --> + + diff --git a/README.md b/README.md index 7e87fb98..72a9b613 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ This document serves as the master index for the SCADA system design. The system ### Scale -- ~10 site clusters, each with 50–500 machines, 25–75 live tags per machine. - Central cluster: 2-node active/standby behind a load balancer. - 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). | | 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. | -| 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 | Central–Site 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. | | 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. | diff --git a/docker/Dockerfile b/docker/Dockerfile index 0315bc0d..54abc8ec 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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. 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_ 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) # 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 diff --git a/docker/build.sh b/docker/build.sh index ebbe69f9..52957617 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -12,11 +12,21 @@ if ! docker network inspect scadabridge-net >/dev/null 2>&1; then docker network create scadabridge-net 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) echo "Building scadabridge:latest image..." docker build \ -t scadabridge:latest \ -f "$SCRIPT_DIR/Dockerfile" \ + "${NUGET_ARGS[@]}" \ "$REPO_ROOT" echo "Build complete: scadabridge:latest" diff --git a/docs/plans/2026-05-28-mxgateway-data-connection-design.md b/docs/plans/2026-05-28-mxgateway-data-connection-design.md new file mode 100644 index 00000000..c5365ffe --- /dev/null +++ b/docs/plans/2026-05-28-mxgateway-data-connection-design.md @@ -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 ` | +| `ClientName` | string | `scadabridge-` | 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())); +``` +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`. diff --git a/docs/plans/2026-05-28-mxgateway-data-connection.md b/docs/plans/2026-05-28-mxgateway-data-connection.md new file mode 100644 index 00000000..3fe83691 --- /dev/null +++ b/docs/plans/2026-05-28-mxgateway-data-connection.md @@ -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` → `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`, `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, 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`; `WriteBulkAsync(serverHandle, IReadOnlyList)→IReadOnlyList`; `StreamEventsAsync(afterWorkerSequence)→IAsyncEnumerable`. 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` (``); projects reference `` 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 + + + + + + + + + +``` + +**Step 2: Add package versions to `Directory.Packages.props`** (under the existing ``; 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 + + +``` + +**Step 3: Add the PackageReference to the DCL csproj** (in the first ``, after the OPC UA reference): + +```xml + +``` +(`…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; + +/// +/// Per-endpoint configuration for an MxGateway data connection. Serialized to the +/// typed JSON shape stored in DataConnection.PrimaryConfiguration / +/// BackupConfiguration. Both primary and backup use this same shape — the +/// backup is simply a second gateway endpoint for failover. +/// +public class MxGatewayEndpointConfig +{ + /// Gateway base URL (e.g. "http://localhost:5000"). + public string Endpoint { get; set; } = "http://localhost:5000"; + /// API key sent to the gateway as authorization: Bearer <key>. + public string ApiKey { get; set; } = ""; + /// MXAccess client registration name. Blank → derive "scadabridge-<connName>" at connect time. + public string ClientName { get; set; } = ""; + /// MXAccess user id applied to every write-back. 0 = no user context. + public int WriteUserId { get; set; } + /// Use TLS to a secured gateway. + public bool UseTls { get; set; } + /// Path to the CA certificate (TLS only). + public string CaFile { get; set; } = ""; + /// TLS server-name override. + public string ServerName { get; set; } = ""; + /// ReadBulk per-call timeout in milliseconds. + 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 { ["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; + +/// +/// Serializes to/from the typed JSON stored in +/// DataConnection.PrimaryConfiguration / BackupConfiguration, and flattens +/// it to the IDictionary<string,string> shape IDataConnection.ConnectAsync +/// expects. MxGateway is net-new, so there is no legacy shape to recover. +/// +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(json, JsonOpts) ?? new MxGatewayEndpointConfig(); } + catch (JsonException) { return new MxGatewayEndpointConfig(); } + } + + public static IDictionary ToFlatDict(MxGatewayEndpointConfig c) => new Dictionary + { + ["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 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 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; + +/// Connection parameters resolved from the flat config dict. +public record MxGatewayConnectionOptions( + string Endpoint, string ApiKey, string ClientName, int WriteUserId, + bool UseTls, string? CaFile, string? ServerName, int ReadTimeoutMs); + +/// One advised-tag value change pushed from the gateway event stream. +public record MxValueUpdate(string TagPath, object? Value, QualityCode Quality, DateTimeOffset Timestamp); + +/// Per-tag read outcome. +public record MxReadOutcome(string TagPath, bool Success, object? Value, QualityCode Quality, DateTimeOffset Timestamp, string? Error); + +/// Per-tag write outcome. +public record MxWriteOutcome(string TagPath, bool Success, string? Error); + +/// One node in a Galaxy browse level. +public record MxBrowseChild(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren); + +/// +/// Seam over the MxAccess Gateway .NET client + Galaxy repository client. Decouples +/// from the generated gRPC/protobuf types so the +/// adapter is unit-testable with a fake. The real implementation lives in +/// RealMxGatewayClient (Task 11). +/// +public interface IMxGatewayClient : IAsyncDisposable +{ + /// Opens the gateway session and registers the client (Register → serverHandle held internally). + Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default); + + /// Closes the session. + Task DisconnectAsync(CancellationToken ct = default); + + /// AddItem + Advise; returns the gateway item handle (as a string subscription id). + Task SubscribeAsync(string tagPath, CancellationToken ct = default); + + /// UnAdvise + RemoveItem for a previously returned subscription id. + Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default); + + /// Snapshot read of one or more tags (ReadBulk). + Task> ReadAsync(IReadOnlyList tagPaths, CancellationToken ct = default); + + /// Write one or more tag/value pairs (WriteBulk with the configured WriteUserId). + Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default); + + /// One Galaxy browse level (BrowseChildren). parentNodeId null → root. + Task<(IReadOnlyList Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default); + + /// + /// Long-running event consumer. Invokes 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. + /// + Task RunEventLoopAsync(Action onUpdate, CancellationToken ct = default); +} + +/// Builds instances. +public interface IMxGatewayClientFactory +{ + IMxGatewayClient Create(); +} +``` + +**Step 2: Write `MxGatewayGlobalOptions.cs`** + +```csharp +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer; + +/// +/// Deployment-wide MxGateway defaults, bound from the "MxGateway" section of +/// appsettings.json. Per-endpoint behavior lives on MxGatewayEndpointConfig. +/// +public class MxGatewayGlobalOptions +{ + /// Prefix used to derive a per-connection client registration name when the connection's ClientName is blank. + 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 Subscribed = new(); + public readonly TaskCompletionSource EventLoopGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Action? OnUpdate; + public Func, IReadOnlyList>? ReadHandler; + public Func, IReadOnlyList>? WriteHandler; + public Func, 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 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> ReadAsync(IReadOnlyList tags, CancellationToken ct = default) => Task.FromResult(ReadHandler!(tags)); + public Task> WriteAsync(IReadOnlyList<(string, object?)> w, CancellationToken ct = default) => Task.FromResult(WriteHandler!(w)); + public Task<(IReadOnlyList, bool)> BrowseChildrenAsync(string? p, CancellationToken ct = default) => Task.FromResult(BrowseHandler!(p)); + public async Task RunEventLoopAsync(Action 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 _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 _subs = new(); + private readonly ConcurrentDictionary _tagToSub = new(); + private int _disconnectFired; // 0 = not fired, 1 = fired — Interlocked guard, mirrors OpcUaDataConnection. + + public MxGatewayDataConnection(IMxGatewayClientFactory clientFactory, ILogger logger) + { _clientFactory = clientFactory; _logger = logger; } + + public ConnectionHealth Status => _status; + public event Action? Disconnected; + + public async Task ConnectAsync(IDictionary 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 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 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> ReadBatchAsync(IEnumerable 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 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> WriteBatchAsync(IDictionary 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 WriteBatchAndWaitAsync( + IDictionary 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 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 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())` 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`; add an optional `IOptions` 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())); +``` + +**Step 3: Bind options** in `ServiceCollectionExtensions.AddDataConnectionLayer` (after the `OpcUaGlobalOptions` bind): + +```csharp + services.AddOptions() + .BindConfiguration("MxGateway"); +``` + +**Step 4: Add the flatten branch** in `DeploymentManagerActor.FlattenConnectionConfig` (before the generic fallback): + +```csharp + if (string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase)) + { + var config = Commons.Serialization.MxGatewayEndpointConfigSerializer.Deserialize(json); + return Commons.Serialization.MxGatewayEndpointConfigSerializer.ToFlatDict(config); + } +``` + +**Step 5:** Run tests, verify PASS; build the solution. **Step 6:** Commit `feat(dcl): register MxGateway protocol in factory + config flatten + options binding`. + +--- + +## Task 13: Rename browse message types to protocol-agnostic names + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 2–11 (adapter track) — **NOT** Task 12 (shared file `DeploymentManagerActor.cs`) + +Mechanical rename. No new test — the existing browse tests are the regression guard. **Rename map** (old → new): +- `BrowseOpcUaNodeCommand` → `BrowseNodeCommand` +- `BrowseOpcUaNodeResult` → `BrowseNodeResult` +- `CommunicationService.BrowseOpcUaNodeAsync` → `BrowseNodeAsync` + +**Files (every reference — from the inventory):** +- `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs` (definitions; update the OPC-UA-specific XML doc comments to be protocol-neutral) +- `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs:360-371` (method + return type) +- `src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs:155,158` +- `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs:152,157` (browse routing — NOT the FlattenConnectionConfig method) +- `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs:49,118-142` +- `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs:237,310,435,985-1046` +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs`, `OpcUaBrowseService.cs` (type refs only; the service rename is Task 14) +- `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs:11` (doc cross-ref) +- Any test files referencing `BrowseOpcUaNode*` (grep the `tests/` tree). + +**Step 1:** `grep -rn "BrowseOpcUaNode" src tests --include="*.cs" --include="*.razor"` to get the live list. **Step 2:** Rename across all hits (keep `BrowseFailure`/`BrowseFailureKind`/`BrowseNode`/`BrowseChildrenResult` unchanged — already generic). **Step 3:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` — expected PASS. **Step 4:** Run existing browse tests: `dotnet test --filter "FullyQualifiedName~Browse"` — expected PASS. **Step 5:** Commit `refactor(browse): rename BrowseOpcUaNode* to protocol-agnostic BrowseNode*`. + +--- + +## Task 14: Rename browse service + dialog to protocol-agnostic names + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Tasks 2–11 +**Depends on:** Task 13 + +Mechanical rename within Central UI. **Rename map:** +- `IOpcUaBrowseService` → `IBrowseService`; `OpcUaBrowseService` → `BrowseService` (file renames too) +- `OpcUaBrowserDialog.razor` → `NodeBrowserDialog.razor` (component + file rename) +- Modal title `"Browse OPC UA — @ConnectionName"` → `"Browse — @ConnectionName"` (protocol-neutral) + +**Files:** +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs` → `IBrowseService.cs` +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs` → `BrowseService.cs` (update `BrowseOpcUaNodeAsync` call to `BrowseNodeAsync` from Task 13; update doc comments) +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/OpcUaBrowserDialog.razor` → `NodeBrowserDialog.razor` +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs:56` (DI registration) +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs:13`, `Services/IBindingTester.cs:19` (type refs) +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/TreeRow.razor:49-51` +- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor:370,378,410` (`` 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 ConfigChanged`, `[Parameter] public IReadOnlyList 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 ``. Add: +- A protocol ` + @RenderFieldError("Endpoint") + +
+ + + @RenderFieldError("ApiKey") +
+ + +
+
+ + +
+
+ + +
+
+ + + @RenderFieldError("ReadTimeoutMs") +
+
+ +
Transport security
+
+
+
+ + +
+
+ @if (Config.UseTls) + { +
+ + +
+
+ + +
+ } +
+ + +@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 + : @
@match.Message
; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index e2f717e7..7ab70a3f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -118,7 +118,7 @@ { var connId = GetBindingConnectionId(attr.Name); var canBrowse = connId > 0; - var isOpcUa = IsOpcUa(connId); + var isBrowsable = IsBrowsable(connId); @attr.Name @attr.DataSourceReference @@ -140,11 +140,11 @@ placeholder="@(attr.DataSourceReference ?? "(no default)")" /> - @if (isOpcUa) + @if (isBrowsable) { @@ -367,7 +367,7 @@ @* OPC UA Tag Browser dialog (Task 18) — rendered once; OpenBrowser tracks which binding row's override input receives the picked node id. *@ - } @@ -407,7 +407,7 @@ // OPC UA tag browser (Task 18) — single dialog rendered at page bottom; // _browserAttrInEdit tracks which row gets the picked node id on Select. - private OpcUaBrowserDialog? _browserRef; + private NodeBrowserDialog? _browserRef; private string? _browserAttrInEdit; private string _browserSiteIdentifier = ""; private string _browserConnectionName = ""; @@ -566,13 +566,19 @@ private string? GetTemplateDefault(string attrName) => _bindingDataSourceAttrs.FirstOrDefault(a => a.Name == attrName)?.DataSourceReference; - /// True when the row's selected data connection is OPC UA. - private bool IsOpcUa(int connectionId) - => connectionId > 0 - && string.Equals( - _siteConnections.FirstOrDefault(c => c.Id == connectionId)?.Protocol, - "OpcUa", - StringComparison.OrdinalIgnoreCase); + /// + /// True when the row's selected data connection supports address-space browsing + /// (the tag picker). OPC UA and MxGateway both implement + /// IBrowsableDataConnection site-side; other protocols return a + /// NotBrowsable failure, so the button is hidden for them. + /// + 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); + } /// /// Opens the OPC UA tag browser dialog for the given attribute row. Remembers diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor index 3d8314fe..a96df290 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Design/DataConnectionForm.razor @@ -49,17 +49,45 @@ } +
+ + @if (_protocolLocked) + { + +
Protocol is locked after creation.
+ } + else + { + + } +
Primary endpoint
- + @if (_protocol == "MxGateway") + { + + } + else + { + + }
Backup endpoint @@ -77,11 +105,21 @@ } else { - + @if (_protocol == "MxGateway") + { + + } + else + { + + }
s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}"; _siteLocked = true; _formName = _editingConnection.Name; + _protocol = string.IsNullOrWhiteSpace(_editingConnection.Protocol) ? "OpcUa" : _editingConnection.Protocol; + _protocolLocked = true; - (_primaryConfig, _primaryIsLegacy) = - OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration); - - if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration)) - { - (_backupConfig, _backupIsLegacy) = - OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration); - _showBackup = true; - _formFailoverRetryCount = _editingConnection.FailoverRetryCount; - } + LoadConfig(_editingConnection); } } 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() { _formError = null; if (_formSiteId == 0) { _formError = "Site is required."; return; } if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; } - _primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary."); - _backupErrors = _showBackup - ? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.") - : null; + string primaryJson; + string? backupJson; - if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false })) + if (_protocol == "MxGateway") { - _formError = "Fix the errors below before saving."; - return; - } + _primaryErrors = MxGatewayEndpointConfigValidator.Validate(_primaryMx, "Primary."); + _backupErrors = _showBackup + ? MxGatewayEndpointConfigValidator.Validate(_backupMx, "Backup.") + : null; - var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig); - var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null; + if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false })) + { + _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 { if (_editingConnection != null) { _editingConnection.Name = _formName.Trim(); - _editingConnection.Protocol = "OpcUa"; + _editingConnection.Protocol = _protocol; _editingConnection.PrimaryConfiguration = primaryJson; _editingConnection.BackupConfiguration = backupJson; _editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3; @@ -210,7 +293,7 @@ } else { - var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId) + var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId) { PrimaryConfiguration = primaryJson, BackupConfiguration = backupJson, @@ -233,6 +316,7 @@ { _showBackup = false; _backupConfig = new OpcUaEndpointConfig(); + _backupMx = new MxGatewayEndpointConfig(); _backupIsLegacy = false; _formFailoverRetryCount = 3; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs index 7db879ac..aa802860 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ServiceCollectionExtensions.cs @@ -50,10 +50,10 @@ public static class ServiceCollectionExtensions // Backs the Audit Log page's Export button via GET /api/centralui/audit/export. services.AddScoped(); - // 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 // transport failures into typed BrowseFailure results for the dialog. - services.AddScoped(); + services.AddScoped(); // Test Bindings: facade over CommunicationService.ReadTagValuesAsync — // same Design-role guard + typed-failure translation as the browse diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs index 2c19d8b1..e8e1c7d5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs @@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// that enforces the /// CentralUI-side Design-role trust boundary and translates transport /// exceptions into a typed result. Mirrors -/// . +/// . ///
public sealed class BindingTester : IBindingTester { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs similarity index 81% rename from src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs rename to src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs index 1fb2d686..b27f4298 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/OpcUaBrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs @@ -7,8 +7,8 @@ using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// -/// Default implementation — a thin facade over -/// that enforces the +/// Default implementation — a thin facade over +/// that enforces the /// CentralUI-side Design-role trust boundary and translates transport /// exceptions into a typed result. /// @@ -19,24 +19,24 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// ServerError so the dialog can show an inline banner while leaving the /// manual node-id paste field usable. /// -public sealed class OpcUaBrowseService : IOpcUaBrowseService +public sealed class BrowseService : IBrowseService { private readonly CommunicationService _communication; private readonly AuthenticationStateProvider _auth; /// - /// Initializes a new instance of the . + /// Initializes a new instance of the . /// /// Central-side cluster communication service. /// Authentication state provider used for the Design-role guard. - public OpcUaBrowseService(CommunicationService communication, AuthenticationStateProvider auth) + public BrowseService(CommunicationService communication, AuthenticationStateProvider auth) { _communication = communication ?? throw new ArgumentNullException(nameof(communication)); _auth = auth ?? throw new ArgumentNullException(nameof(auth)); } /// - public async Task BrowseChildrenAsync( + public async Task BrowseChildrenAsync( string siteId, string connectionName, string? parentNodeId, @@ -47,7 +47,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService var state = await _auth.GetAuthenticationStateAsync(); if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) { - return new BrowseOpcUaNodeResult( + return new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure(BrowseFailureKind.ServerError, "Not authorized.")); @@ -55,9 +55,9 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService try { - return await _communication.BrowseOpcUaNodeAsync( + return await _communication.BrowseNodeAsync( siteId, - new BrowseOpcUaNodeCommand(connectionName, parentNodeId), + new BrowseNodeCommand(connectionName, parentNodeId), cancellationToken); } 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 // within CommunicationOptions.QueryTimeout. Surface as a typed // Timeout failure so the dialog can render an inline banner. - return new BrowseOpcUaNodeResult( + return new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure(BrowseFailureKind.Timeout, ex.Message)); @@ -80,7 +80,7 @@ public sealed class OpcUaBrowseService : IOpcUaBrowseService { // Any other transport / serialization failure: keep the dialog // alive and let the user fall back to manual node-id paste. - return new BrowseOpcUaNodeResult( + return new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure(BrowseFailureKind.ServerError, ex.Message)); diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs index 91a08dde..028987e8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBindingTester.cs @@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// envelope. Transport failures (timeouts, unreachable sites) are translated /// into a typed so the dialog can render an /// inline banner without crashing — same shape as -/// . +/// . /// public interface IBindingTester { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs similarity index 92% rename from src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs rename to src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs index ea9aaf0e..f70f2025 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IOpcUaBrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/IBrowseService.cs @@ -6,7 +6,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// 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 /// , which forwards a -/// to the owning site via +/// to the owning site via /// . /// /// @@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; /// so the dialog can render an inline error and /// remain usable (manual node-id paste still works). /// -public interface IOpcUaBrowseService +public interface IBrowseService { /// /// Enumerates the immediate children of an OPC UA node on the live server @@ -29,7 +29,7 @@ public interface IOpcUaBrowseService /// Name of the site-local data connection to browse against — the site's DataConnectionManagerActor indexes its children by name. /// Node to browse, or null to browse from the server root. /// Cancellation token. - Task BrowseChildrenAsync( + Task BrowseChildrenAsync( string siteId, string connectionName, string? parentNodeId, diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs index 73fffa58..c72c404f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/BrowseCommands.cs @@ -15,11 +15,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; /// /// Name of the site-local data connection to browse against. /// Node to browse, or null to browse from the server root (ObjectsFolder). -public record BrowseOpcUaNodeCommand( +public record BrowseNodeCommand( string ConnectionName, string? ParentNodeId); -public record BrowseOpcUaNodeResult( +public record BrowseNodeResult( IReadOnlyList Children, bool Truncated, BrowseFailure? Failure); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs index b0416016..3be3ab78 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/ReadTagValuesCommand.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; /// /// /// Keyed by (not id) for the same reason as -/// : the site-side +/// : the site-side /// DataConnectionManagerActor indexes its children by connection name, /// and the central UI already has the connection name in scope from the /// bindings table. The central DataConnections table's id is not diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs new file mode 100644 index 00000000..40282828 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Serialization/MxGatewayEndpointConfigSerializer.cs @@ -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; + +/// +/// Serializes to/from the typed JSON stored in +/// DataConnection.PrimaryConfiguration / BackupConfiguration, and flattens +/// it to the IDictionary<string,string> shape IDataConnection.ConnectAsync +/// expects. MxGateway is net-new, so there is no legacy shape to recover — a row that +/// fails to parse yields a default config. +/// +public static class MxGatewayEndpointConfigSerializer +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + /// Serializes a config to the typed JSON shape. + /// The endpoint configuration to serialize. + public static string Serialize(MxGatewayEndpointConfig config) + => JsonSerializer.Serialize(config, JsonOpts); + + /// Parses stored config JSON; null/blank/malformed yields a default config. + /// The stored JSON string. + public static MxGatewayEndpointConfig Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new MxGatewayEndpointConfig(); + try { return JsonSerializer.Deserialize(json, JsonOpts) ?? new MxGatewayEndpointConfig(); } + catch (JsonException) { return new MxGatewayEndpointConfig(); } + } + + /// Flattens the typed config to the key-value shape the adapter consumes. + /// The endpoint configuration to flatten. + public static IDictionary ToFlatDict(MxGatewayEndpointConfig c) => new Dictionary + { + ["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), + }; + + /// Reconstructs a config from the flat key-value shape; invalid numerics fall back to defaults. + /// The flat dictionary. + public static MxGatewayEndpointConfig FromFlatDict(IDictionary 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; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs new file mode 100644 index 00000000..f1fdbd29 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/DataConnections/MxGatewayEndpointConfig.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections; + +/// +/// Per-endpoint configuration for an MxGateway data connection. Serialized to the +/// typed JSON shape stored in DataConnection.PrimaryConfiguration / +/// BackupConfiguration. Both primary and backup use this same shape — the +/// backup is simply a second gateway endpoint for failover. +/// +public class MxGatewayEndpointConfig +{ + /// Gateway base URL (e.g. "http://localhost:5000"). + public string Endpoint { get; set; } = "http://localhost:5000"; + /// API key sent to the gateway as authorization: Bearer <key>. + public string ApiKey { get; set; } = ""; + /// MXAccess client registration name. Blank → derive "scadabridge-<connName>" at connect time. + public string ClientName { get; set; } = ""; + /// MXAccess user id applied to every write-back. 0 = no user context. + public int WriteUserId { get; set; } + /// Use TLS to a secured gateway. + public bool UseTls { get; set; } + /// Path to the CA certificate (TLS only). + public string CaFile { get; set; } = ""; + /// TLS server-name override. + public string ServerName { get; set; } = ""; + /// ReadBulk per-call timeout in milliseconds. + public int ReadTimeoutMs { get; set; } = 5000; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Validators/MxGatewayEndpointConfigValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Validators/MxGatewayEndpointConfigValidator.cs new file mode 100644 index 00000000..8443e77d --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Validators/MxGatewayEndpointConfigValidator.cs @@ -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; + +/// +/// Pure-function validator for . Errors carry +/// the offending property name in +/// (optionally prefixed, e.g. "Primary.Endpoint") so the form can render +/// per-field messages. +/// +public static class MxGatewayEndpointConfigValidator +{ + /// + /// Validates all fields of an , returning errors with optionally-prefixed field names. + /// + /// The MxGateway endpoint configuration to validate. + /// Optional prefix prepended to each field name in error entries (e.g., "Primary."). + public static ValidationResult Validate(MxGatewayEndpointConfig config, string fieldPrefix = "") + { + var errors = new List(); + + 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}"); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs index b82596d8..d3641c52 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs @@ -152,10 +152,10 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers // DataConnectionActor children (which own the live OPC UA sessions) // only exist on the singleton's node. The singleton then re-forwards // to its own /user/dcl-manager, which DOES have the connection. - Receive(msg => _deploymentManagerProxy.Forward(msg)); + Receive(msg => _deploymentManagerProxy.Forward(msg)); // 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 // children holding the live OPC UA sessions. Receive(msg => _deploymentManagerProxy.Forward(msg)); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index 8d06e110..816ff8d3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -360,13 +360,13 @@ public class CommunicationService /// The OPC UA browse command. /// Cancellation token. /// The browse result (children + truncation flag + structured failure). - public Task BrowseOpcUaNodeAsync( + public Task BrowseNodeAsync( string siteId, - BrowseOpcUaNodeCommand command, + BrowseNodeCommand command, CancellationToken cancellationToken = default) { var envelope = new SiteEnvelope(siteId, command); - return GetActor().Ask( + return GetActor().Ask( envelope, _options.QueryTimeout, cancellationToken); } @@ -377,7 +377,7 @@ public class CommunicationService /// server backing the given data connection. Used by the CentralUI "Test /// Bindings" dialog on the Configure Instance page. The Ask is bounded by /// — same latency budget - /// as (both are interactive one-shot + /// as (both are interactive one-shot /// design-time queries). /// /// The target site identifier. diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs index 918111f7..74bd2cff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionActor.cs @@ -234,7 +234,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // apply it so its state survives into the next ReSubscribeAll. HandleSubscribeCompleted(sc); break; - case BrowseOpcUaNodeCommand browse: + case BrowseNodeCommand browse: // Browse is an interactive design-time query; never stash. The // adapter has no session yet in this state, so reply with a // typed ConnectionNotConnected failure so the dialog can render @@ -307,7 +307,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers case RetryTagResolution: HandleRetryTagResolution(); break; - case BrowseOpcUaNodeCommand browse: + case BrowseNodeCommand browse: HandleBrowse(browse); break; case ReadTagValuesCommand read: @@ -432,7 +432,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // apply it so its state survives into the next ReSubscribeAll. HandleSubscribeCompleted(sc); break; - case BrowseOpcUaNodeCommand browse: + case BrowseNodeCommand browse: // Browse is design-time and never stashed. While reconnecting // the adapter has no live session, so the adapter call will // throw ConnectionNotConnectedException — mapped by HandleBrowse. @@ -982,7 +982,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers // ── OPC UA Tag Browser (interactive design-time query) ── /// - /// Handles a forwarded by the + /// Handles a forwarded by the /// . The capability check (does /// this adapter support browsing?) and all browse-failure mapping live /// here because the adapter is held by this actor, not the manager. @@ -999,14 +999,14 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers /// — so the captured is /// safe to use from the continuation (which runs off the actor thread). /// - private void HandleBrowse(BrowseOpcUaNodeCommand command) + private void HandleBrowse(BrowseNodeCommand command) { var sender = Sender; if (_adapter is not IBrowsableDataConnection browsable) { _log.Debug("[{0}] Browse requested but adapter does not implement IBrowsableDataConnection", _connectionName); - sender.Tell(new BrowseOpcUaNodeResult( + sender.Tell(new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure( @@ -1021,21 +1021,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers { 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(); return baseEx switch { - ConnectionNotConnectedException notConnected => new BrowseOpcUaNodeResult( + ConnectionNotConnectedException notConnected => new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, notConnected.Message)), - OperationCanceledException => new BrowseOpcUaNodeResult( + OperationCanceledException => new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure(BrowseFailureKind.Timeout, "Browse cancelled.")), - _ => new BrowseOpcUaNodeResult( + _ => new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure( diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs index 1bf21ee0..1bab2494 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Actors/DataConnectionManagerActor.cs @@ -46,7 +46,7 @@ public class DataConnectionManagerActor : ReceiveActor Receive(HandleRouteWrite); Receive(HandleRemoveConnection); Receive(HandleGetAllHealthReports); - Receive(HandleBrowse); + Receive(HandleBrowse); Receive(HandleReadTagValues); } @@ -115,7 +115,7 @@ public class DataConnectionManagerActor : ReceiveActor } /// - /// Routes a from the central UI's OPC UA + /// Routes a from the central UI's OPC UA /// Tag Browser to the child that owns the /// named connection. The manager is the only actor that knows whether a /// 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 /// child where the adapter is held. /// - private void HandleBrowse(BrowseOpcUaNodeCommand command) + private void HandleBrowse(BrowseNodeCommand command) { if (_connectionActors.TryGetValue(command.ConnectionName, out var actor)) { @@ -132,7 +132,7 @@ public class DataConnectionManagerActor : ReceiveActor else { _log.Warning("No connection actor for {0} during browse", command.ConnectionName); - Sender.Tell(new BrowseOpcUaNodeResult( + Sender.Tell(new BrowseNodeResult( Array.Empty(), Truncated: false, new BrowseFailure( diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs new file mode 100644 index 00000000..db9eefca --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IMxGatewayClient.cs @@ -0,0 +1,79 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +/// Connection parameters resolved from the flat config dict. +public record MxGatewayConnectionOptions( + string Endpoint, string ApiKey, string ClientName, int WriteUserId, + bool UseTls, string? CaFile, string? ServerName, int ReadTimeoutMs); + +/// One advised-tag value change pushed from the gateway event stream. +public record MxValueUpdate(string TagPath, object? Value, QualityCode Quality, DateTimeOffset Timestamp); + +/// Per-tag read outcome. +public record MxReadOutcome(string TagPath, bool Success, object? Value, QualityCode Quality, DateTimeOffset Timestamp, string? Error); + +/// Per-tag write outcome. +public record MxWriteOutcome(string TagPath, bool Success, string? Error); + +/// One node in a Galaxy browse level. +public record MxBrowseChild(string NodeId, string DisplayName, BrowseNodeClass NodeClass, bool HasChildren); + +/// +/// Seam over the MxAccess Gateway .NET client + Galaxy repository client. Decouples +/// from the generated gRPC/protobuf types so the +/// adapter is unit-testable with a fake. The real implementation lives in +/// RealMxGatewayClient. +/// +public interface IMxGatewayClient : IAsyncDisposable +{ + /// Opens the gateway session and registers the client (Register → serverHandle held internally). + /// Resolved connection parameters. + /// Cancellation token. + Task ConnectAsync(MxGatewayConnectionOptions options, CancellationToken ct = default); + + /// Closes the session. + /// Cancellation token. + Task DisconnectAsync(CancellationToken ct = default); + + /// AddItem + Advise; returns the gateway item handle (as a string subscription id). + /// Tag address to subscribe to. + /// Cancellation token. + Task SubscribeAsync(string tagPath, CancellationToken ct = default); + + /// UnAdvise + RemoveItem for a previously returned subscription id. + /// Subscription id returned by . + /// Cancellation token. + Task UnsubscribeAsync(string subscriptionId, CancellationToken ct = default); + + /// Snapshot read of one or more tags (ReadBulk). + /// Tag addresses to read. + /// Cancellation token. + Task> ReadAsync(IReadOnlyList tagPaths, CancellationToken ct = default); + + /// Write one or more tag/value pairs (WriteBulk with the configured WriteUserId). + /// Tag/value pairs to write. + /// Cancellation token. + Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> writes, CancellationToken ct = default); + + /// One Galaxy browse level (BrowseChildren). null → root. + /// Parent node id (Galaxy contained path), or null for root. + /// Cancellation token. + Task<(IReadOnlyList Children, bool Truncated)> BrowseChildrenAsync(string? parentNodeId, CancellationToken ct = default); + + /// + /// Long-running event consumer. Invokes 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. + /// + /// Callback invoked per advised-tag value change. + /// Cancellation token; ends the loop when cancelled. + Task RunEventLoopAsync(Action onUpdate, CancellationToken ct = default); +} + +/// Builds instances. +public interface IMxGatewayClientFactory +{ + /// Creates a new, unconnected client instance. + IMxGatewayClient Create(); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs new file mode 100644 index 00000000..79d9b8e2 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs @@ -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; + +/// +/// MxGateway adapter implementing + . +/// Maps IDataConnection concepts onto the MxAccess Gateway session model via the +/// seam: +/// +/// Connect → OpenSession + Register, then a background event loop. +/// Subscribe → AddItem + Advise; value changes arrive on the event stream. +/// Read/Write → ReadBulk / WriteBulk. +/// Browse → Galaxy repository BrowseChildren. +/// +/// Reconnection is driven by the DataConnectionActor: a stream fault raises +/// , the actor disposes this adapter, creates a fresh one, +/// reconnects and re-subscribes all tags. +/// +public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection +{ + private readonly IMxGatewayClientFactory _clientFactory; + private readonly ILogger _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 _subs = new(); + private readonly ConcurrentDictionary _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; + + /// Initializes a new instance of . + /// Factory used to create gateway client instances. + /// Logger instance. + public MxGatewayDataConnection(IMxGatewayClientFactory clientFactory, ILogger logger) + { + _clientFactory = clientFactory; + _logger = logger; + } + + /// + public ConnectionHealth Status => _status; + + /// Raised once when the gateway event stream faults (connection lost). + public event Action? Disconnected; + + /// + public async Task ConnectAsync(IDictionary 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(); + } + } + + /// + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + _eventLoopCts?.Cancel(); + if (_client is not null) + await _client.DisconnectAsync(cancellationToken); + _status = ConnectionHealth.Disconnected; + } + + /// + public async Task SubscribeAsync(string tagPath, SubscriptionCallback callback, CancellationToken cancellationToken = default) + { + var subId = await _client!.SubscribeAsync(tagPath, cancellationToken); + _subs[subId] = (tagPath, callback); + _tagToSub[tagPath] = subId; + return subId; + } + + /// + 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); + } + + /// + public async Task ReadAsync(string tagPath, CancellationToken cancellationToken = default) + { + var r = (await _client!.ReadAsync(new[] { tagPath }, cancellationToken)).Single(); + return ToReadResult(r); + } + + /// + public async Task> ReadBatchAsync(IEnumerable 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); + + /// + public async Task 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); + } + + /// + public async Task> WriteBatchAsync(IDictionary 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)); + } + + /// + public async Task WriteBatchAndWaitAsync( + IDictionary 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; + } + + /// + public async Task 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); + } + + /// + public async ValueTask DisposeAsync() + { + _eventLoopCts?.Cancel(); + if (_client is not null) + await _client.DisposeAsync(); + GC.SuppressFinalize(this); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs new file mode 100644 index 00000000..d34a9968 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealMxGatewayClient.cs @@ -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; + +/// +/// Production implementation over the +/// ZB.MOM.WW.MxGateway.Client 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. +/// +public sealed class RealMxGatewayClient : IMxGatewayClient +{ + private readonly ILogger _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 _tagToHandle = new(); + private readonly ConcurrentDictionary _handleToTag = new(); + + /// Initializes a new instance of . + /// Logger factory shared with the gateway client. + public RealMxGatewayClient(ILoggerFactory? loggerFactory) + { + _loggerFactory = loggerFactory; + _logger = (loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance) + .CreateLogger(); + } + + /// + 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); + } + + /// + public async Task DisconnectAsync(CancellationToken ct = default) + { + if (_session is not null) + await _session.CloseAsync(ct).ConfigureAwait(false); + } + + /// + public async Task 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); + } + + /// + 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 _); + } + + /// + public async Task> ReadAsync(IReadOnlyList 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(); + } + + /// + public async Task> 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(writes.Count); + var orderedTags = new List(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(); + } + + /// + public async Task<(IReadOnlyList 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(); + 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)); + } + + /// + public async Task RunEventLoopAsync(Action 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)); + } + } + + /// + 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 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; + } + + /// + /// Maps MXAccess quality. A failing status proxy is authoritative bad; otherwise + /// the OPC-style quality byte: ≥192 Good, ≥64 Uncertain, else Bad. + /// + private static QualityCode MapQuality(int quality, IEnumerable 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(), + }; +} + +/// Builds instances. +public sealed class RealMxGatewayClientFactory : IMxGatewayClientFactory +{ + private readonly ILoggerFactory? _loggerFactory; + + /// Initializes a new factory. + /// Logger factory passed to each created client. + public RealMxGatewayClientFactory(ILoggerFactory? loggerFactory) => _loggerFactory = loggerFactory; + + /// + public IMxGatewayClient Create() => new RealMxGatewayClient(_loggerFactory); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs index 11819cd5..9c9d12f4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionFactory.cs @@ -38,6 +38,13 @@ public class DataConnectionFactory : IDataConnectionFactory RegisterAdapter("OpcUa", details => new OpcUaDataConnection( new RealOpcUaClientFactory(globalOptions, _loggerFactory), _loggerFactory.CreateLogger())); + + // 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())); } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/MxGatewayGlobalOptions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/MxGatewayGlobalOptions.cs new file mode 100644 index 00000000..190f1017 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/MxGatewayGlobalOptions.cs @@ -0,0 +1,11 @@ +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer; + +/// +/// Deployment-wide MxGateway defaults, bound from the "MxGateway" section of +/// appsettings.json. Per-endpoint behavior lives on MxGatewayEndpointConfig. +/// +public class MxGatewayGlobalOptions +{ + /// Prefix used to derive a per-connection client registration name when the connection's ClientName is blank. + public string ClientNamePrefix { get; set; } = "scadabridge"; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs index dbb169bb..42cf2e6c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs @@ -18,6 +18,9 @@ public static class ServiceCollectionExtensions services.AddOptions() .BindConfiguration("OpcUa"); + services.AddOptions() + .BindConfiguration("MxGateway"); + // WP-34: Register the factory for protocol extensibility services.AddSingleton(); diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj index 78302527..7a786ed4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.csproj @@ -16,6 +16,7 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index a7175a7f..d53212e4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -149,12 +149,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(RouteInboundApiSetAttributes); // 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 // to be the one holding the live DataConnectionActor children. ActorSelection // has no Forward() extension in this Akka.NET version, so we Tell with the // original Sender preserved (semantically identical to Forward). - Receive(msg => + Receive(msg => Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender)); // 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); } + 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. try { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs index b9d378b2..aaac4756 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs @@ -44,12 +44,55 @@ public class DataConnectionFormTests : BunitContext } [Fact] - public void NoProtocolDropdown_IsRendered() + public void ProtocolDropdown_IsRendered_OnCreate_WithBothProtocols() { var cut = RenderForCreateSite(1); - Assert.DoesNotContain("Custom", cut.Markup); 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(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] diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs index 9e9f5287..a33eb66b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs @@ -44,10 +44,10 @@ public class InstanceConfigureAuditDrillinTests : BunitContext Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For())); Services.AddSingleton(Substitute.For()); - // The page renders and at + // The page renders and at // the bottom; their @inject directives need a registered service even // though this test doesn't open either dialog. - Services.AddSingleton(Substitute.For()); + Services.AddSingleton(Substitute.For()); Services.AddSingleton(Substitute.For()); // Auth: a system-wide Deployment user so SiteScope grants everything. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs index a2042bd3..6fa96525 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Messages/BrowseCommandsRegistryTests.cs @@ -3,7 +3,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; /// -/// Verifies that is discovered by +/// Verifies that is discovered by /// so it travels over the management /// boundary as a known command (resolvable by wire name and round-trippable /// through GetCommandName / Resolve). @@ -11,13 +11,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Messages; public class BrowseCommandsRegistryTests { [Fact] - public void Registry_discovers_BrowseOpcUaNodeCommand() + public void Registry_discovers_BrowseNodeCommand() { // GetCommandName throws ArgumentException for any type the registry // 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(typeof(BrowseOpcUaNodeCommand), ManagementCommandRegistry.Resolve(name)); + Assert.Equal("BrowseNode", name); + Assert.Equal(typeof(BrowseNodeCommand), ManagementCommandRegistry.Resolve(name)); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs new file mode 100644 index 00000000..2493111c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/DataConnections/MxGatewayEndpointConfigSerializerTests.cs @@ -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 { ["ReadTimeoutMs"] = "not-a-number" }); + Assert.Equal(new MxGatewayEndpointConfig().ReadTimeoutMs, back.ReadTimeoutMs); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs new file mode 100644 index 00000000..0813132a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validators/MxGatewayEndpointConfigValidatorTests.cs @@ -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"); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs index 110fdce3..20933a6d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs @@ -14,7 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Actors; /// Task 10 (opcua-tag-browser): the site-side /// + child /// together resolve -/// against the live adapter and surface +/// against the live adapter and surface /// every browse outcome as a typed . The split is: /// the manager owns (only it /// 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 // browse against any name must be rejected with ConnectionNotFound // (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(); + var reply = ExpectMsg(); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.ConnectionNotFound, reply.Failure!.Kind); Assert.Empty(reply.Children); @@ -80,9 +80,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); - manager.Tell(new BrowseOpcUaNodeCommand("conn-bare", ParentNodeId: null)); + manager.Tell(new BrowseNodeCommand("conn-bare", ParentNodeId: null)); - var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.NotBrowsable, reply.Failure!.Kind); Assert.Empty(reply.Children); @@ -120,9 +120,9 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit () => _factory.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Create"), TimeSpan.FromSeconds(2)); - manager.Tell(new BrowseOpcUaNodeCommand("conn-ok", ParentNodeId: null)); + manager.Tell(new BrowseNodeCommand("conn-ok", ParentNodeId: null)); - var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.Null(reply.Failure); Assert.Equal(2, reply.Children.Count); 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"), TimeSpan.FromSeconds(2)); - manager.Tell(new BrowseOpcUaNodeCommand("conn-down", ParentNodeId: null)); + manager.Tell(new BrowseNodeCommand("conn-down", ParentNodeId: null)); - var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); Assert.NotNull(reply.Failure); Assert.Equal(BrowseFailureKind.ConnectionNotConnected, reply.Failure!.Kind); Assert.Empty(reply.Children); diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs new file mode 100644 index 00000000..1118dedc --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/FakeMxGatewayClient.cs @@ -0,0 +1,65 @@ +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// In-memory fake for adapter unit tests. Lets tests +/// drive the event loop (push updates / fault the stream) and stub read/write/browse. +/// +public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFactory +{ + public MxGatewayConnectionOptions? ConnectedWith; + public readonly List Subscribed = new(); + public readonly List Unsubscribed = new(); + public readonly TaskCompletionSource EventLoopGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Action? OnUpdate; + public Func, IReadOnlyList>? ReadHandler; + public Func, IReadOnlyList>? WriteHandler; + public Func, 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 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> ReadAsync(IReadOnlyList tags, CancellationToken ct = default) + => Task.FromResult(ReadHandler!(tags)); + + public Task> WriteAsync(IReadOnlyList<(string TagPath, object? Value)> w, CancellationToken ct = default) + => Task.FromResult(WriteHandler!(w)); + + public Task<(IReadOnlyList Children, bool Truncated)> BrowseChildrenAsync(string? p, CancellationToken ct = default) + => Task.FromResult(BrowseHandler!(p)); + + public async Task RunEventLoopAsync(Action 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; + + /// Simulate a stream break so the adapter raises Disconnected. + public void FaultEventLoop() => EventLoopGate.TrySetException(new Exception("stream broke")); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs new file mode 100644 index 00000000..a6548105 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/MxGatewayDataConnectionTests.cs @@ -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.Instance); + + private static Dictionary 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(); + 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 { ["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 { ["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 + { + 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( + () => adapter.BrowseChildrenAsync(null)); + } + + private static async Task WaitUntil(Func 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."); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs index d7a31039..97d31170 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/DataConnectionFactoryTests.cs @@ -18,6 +18,16 @@ public class DataConnectionFactoryTests Assert.IsType(connection); } + [Fact] + public void Create_MxGateway_ReturnsMxGatewayAdapter() + { + var factory = new DataConnectionFactory(NullLoggerFactory.Instance); + + var connection = factory.Create("MxGateway", new Dictionary()); + + Assert.IsType(connection); + } + [Fact] public void Create_CaseInsensitive() {