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