# .NET Client Projects The .NET client workspace contains the MXAccess Gateway client library, test CLI, and unit tests. ## Projects | Project | Purpose | |---------|---------| | `ZB.MOM.WW.MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. | | `ZB.MOM.WW.MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. | | `ZB.MOM.WW.MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. | The projects reference `src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj` so the client compiles against the same generated protobuf and gRPC types as the gateway. `clients/dotnet/generated` remains reserved for generator output if a future client build switches to client-local `Grpc.Tools` generation. ## Build And Test ```powershell dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build ``` ## Packaging Create local library and CLI artifacts from the repository root: ```powershell $dotnetPackageOutput = Join-Path (Get-Location) 'artifacts/clients/dotnet' dotnet pack clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj -c Release -p:PackageOutputPath="$dotnetPackageOutput" dotnet publish clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/ZB.MOM.WW.MxGateway.Client.Cli.csproj -c Release -o artifacts/clients/dotnet/mxgw-dotnet ``` The library package references the shared contracts project at build time. The published CLI runs from `artifacts/clients/dotnet/mxgw-dotnet`. ## Regenerating Protobuf Bindings The .NET client uses the generated C# types from `src/ZB.MOM.WW.MxGateway.Contracts/Generated`. Regenerate those files through the contracts project: ```powershell dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj ``` ## Client Usage `MxGatewayClient` opens a gRPC channel to the gateway and attaches the API key to every unary and streaming call as `authorization: Bearer `. Cancellation tokens passed to the public methods flow to the generated gRPC call. Client-side cancellation stops waiting for the gateway response; it does not abort an MXAccess COM call that is already executing inside a worker. ```csharp await using MxGatewayClient client = MxGatewayClient.Create( new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey, }); MxGatewaySession session = await client.OpenSessionAsync(); try { int serverHandle = await session.RegisterAsync("sample-client"); int itemHandle = await session.AddItemAsync( serverHandle, "Area001.Pump001.Speed"); await session.AdviseAsync(serverHandle, itemHandle); } finally { await session.CloseAsync(); } ``` Use `OpenSessionRawAsync`, `CloseSessionRawAsync`, `InvokeAsync`, and `StreamEventsAsync` when tests or parity tools need direct generated protobuf messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply available, and command helpers have `*RawAsync` variants when callers need the complete `MxCommandReply`. ### Event Streaming And Replay Gaps `StreamEventsAsync(afterWorkerSequence)` yields raw generated `MxEvent` messages. Passing a non-zero `afterWorkerSequence` resumes a session's event stream after a known worker sequence — this is the reconnect cursor. If that cursor is *stale* — older than the oldest event the gateway still retains in the session replay ring — the events in between were evicted and cannot be replayed. The gateway signals this by emitting a single **replay-gap sentinel** at the head of the resumed stream: an `MxEvent` with its `ReplayGap` field set, `Family` unspecified, and no body. It means "you missed events — discard local state and re-snapshot." Rather than force callers to inspect the raw sentinel, the client exposes a typed surface, `StreamEventItemsAsync`, which yields `MxEventStreamItem` values: ```csharp await foreach (MxEventStreamItem item in session.StreamEventItemsAsync( afterWorkerSequence: lastSeenSequence)) { if (item.IsReplayGap) { // We missed events: throw away local state and re-snapshot. ReplayGap gap = item.ReplayGap!; // Resume without incurring another gap: lastSeenSequence = gap.OldestAvailableSequence - 1; await ReSnapshotAsync(); continue; } HandleEvent(item.Event); // normal MXAccess event, IsReplayGap == false lastSeenSequence = item.Event.WorkerSequence; } ``` The typed surface never synthesizes or drops events — it only makes the gateway's own sentinel observable. Normal events pass through with `IsReplayGap == false` and `ReplayGap == null`. The gap is only ever produced by `StreamEvents`; the diagnostic drain path never emits it. If you already consume the raw `StreamEventsAsync` (or the client-level stream), the `AsStreamItemsAsync()` extension projects any `IAsyncEnumerable` into the same `MxEventStreamItem` surface. For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of the active alarms the gateway's central monitor currently holds), `StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages keyed by the same monitor), and `AcknowledgeAlarmAsync` (ack by alarm reference, optional comment, ack target). All three accept a cancellation token and pass through the `MxGateway:Alarms` configuration on the server — when alarms are disabled, the gateway returns an empty list / empty stream rather than failing. `MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return the first `CloseSessionReply` instead of sending another close request. ## Values, Status, And Errors The client provides extension helpers for generated protobuf values. Use `ToMxValue()` on .NET scalar values and typed arrays to create `MxValue` instances for `Write` and `Write2`. Use `ToClrValue()` and `GetProjectionKind()` when test or diagnostic code needs to inspect generated `MxValue` replies while preserving `rawDiagnostic`, raw data type fields, and raw byte payloads. `MxStatusProxy.IsSuccess()` and `ToDiagnosticSummary()` expose MXAccess status arrays without collapsing them into a single gateway success flag. Command reply helpers follow the same split: ```csharp reply.EnsureProtocolSuccess(); reply.EnsureMxAccessSuccess(); ``` `EnsureProtocolSuccess()` raises gateway, session, worker, or command exceptions for gateway-level failures. It leaves `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE` to `EnsureMxAccessSuccess()` so callers can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess itself rejects a command. `MxAccessException.Reply` contains the raw generated reply. `EnsureMxAccessSuccess()` follows COM semantics: only a **negative** HRESULT is a failure, so positive success codes such as `S_FALSE` (1) pass. A status entry fails only when `Category` is not `MxStatusCategory.Ok` — `MxStatusProxy.Success` mirrors the raw COM member for diagnostics and never decides the verdict, which is why `IsSuccess()` branches on the category alone. ## Write Semantics And Common Pitfalls These are MXAccess parity behaviors that surprise new callers. The gateway forwards them unchanged — it does not paper over them. ### Attributing a write to a user without `AuthenticateUser` MXAccess only stamps a plain `Write`/`Write2` with a Galaxy user id when the item carries an active *supervisory* advise. If you are **not** using the verified/secured path (`AuthenticateUser` → `WriteSecured`/`WriteSecured2`) but still need the write attributed to a user id, you must first advise the item supervisory and then pass that user id on the write. Without the supervisory advise the `userId` on a plain write is ignored. The library exposes `Advise`/`UnAdvise` as named helpers but not supervisory advise, so send it through the generic command channel: ```csharp await session.InvokeAsync(new MxCommandRequest { SessionId = session.SessionId, Command = new MxCommand { Kind = MxCommandKind.AdviseSupervisory, AdviseSupervisory = new AdviseSupervisoryCommand { ServerHandle = serverHandle, ItemHandle = itemHandle, }, }, }); await session.WriteAsync(serverHandle, itemHandle, value.ToMxValue(), userId); ``` The CLI exposes the same command as `advise-supervisory`, and `write` / `write2` take `--user-id`. ### Array writes replace the whole array A write to an array attribute **replaces the entire array**; it is not an element-wise patch. To change a subset of elements, send the full array with the unchanged elements included. For example, to change 2 elements of a 20-element array, build the `MxValue` from all 20 values (the 18 unchanged plus the 2 new ones). Sending only the 2 changed values overwrites the attribute with a 2-element array. When only a few indices need changing and the rest should be reset to the element type's default, use `WriteArrayElementsAsync` instead of building the full array manually: ```csharp await session.WriteArrayElementsAsync( serverHandle, itemHandle, elementDataType: MxDataType.Integer, totalLength: 20, elements: new Dictionary { [2] = 42.ToMxValue(), [7] = 99.ToMxValue(), }); ``` The gateway expands the sparse descriptor into a full `totalLength`-element array before forwarding to the worker. Indices not listed in `elements` are written as the element type's default — this is a **reset**, not a preserve; current values at those positions are discarded. `totalLength` is required and must match the declared length of the array attribute. Bare-name array items (`Area001.Pump001.Speed`) are auto-normalized to the `[]` form across the whole add family — `AddItem`, `AddItem2`, `AddItemBulk`, and `AddBufferedItem` — so the array attribute accepts the write. ## CLI Usage The test CLI supports deterministic JSON output for automation: ```powershell dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- version --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- register --session-id --client-name mxgw-dotnet-cli --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- add-item --session-id --server-handle 1 --item Area001.Pump001.Speed --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --session-id --server-handle 1 --item-handle 1 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id --server-handle 1 --item-handle 1 --type int32 --value 123 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write2 --session-id --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-events --session-id --max-events 1 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- stream-alarms --filter-prefix Area001 --max-events 1 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- acknowledge-alarm --reference "\\Galaxy\Area001.Pump001.PumpFault" --comment "ack from cli" --operator operator1 --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json ``` `smoke` opens a session, registers a client, adds one item, advises it, optionally writes a value when `--type` and `--value` are supplied, reads a bounded event stream, and closes the session in a `finally` block. CLI error output redacts API keys supplied through `--api-key`. ### `authenticate-user` credentials ```powershell $env:MXGATEWAY_VERIFY_PASSWORD = "" dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- authenticate-user --session-id --server-handle 1 --verify-user operator --json ``` The credential comes from `--password` or, preferably, the environment variable named by `--password-env` (default `MXGATEWAY_VERIFY_PASSWORD`) so it stays out of shell history and the process table. It is never echoed to stdout or stderr, and error output routes it through the same redaction seam as the API key. A missing or empty resolved credential is a usage error naming the option and the variable: the CLI fails before the invoke rather than authenticating with an empty password. `MXGATEWAY_VERIFY_PASSWORD` is the canonical variable across all five client CLIs — see [Cross-Language Smoke Matrix](../../docs/CrossLanguageSmokeMatrix.md). **Deprecated names.** This CLI previously used `--verify-user-password`, `--verify-user-password-env`, and `MXGATEWAY_VERIFY_USER_PASSWORD`. All three still resolve, for one release only, so existing scripts keep working; migrate to the canonical names above. The full resolution order is `--password`, `--verify-user-password`, the variable named by `--password-env` (or the deprecated `--verify-user-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`), then `MXGATEWAY_VERIFY_USER_PASSWORD`. ## Galaxy Repository Browse `GalaxyRepositoryClient` is a separate read-only wrapper around the `GalaxyRepository` gRPC service exposed by the same gateway. It shares the API key auth interceptor with `MxGatewayClient` and requires the `metadata:read` scope server-side. Use it to probe the ZB SQL connection, watch `time_of_last_deploy` for redeployments, and enumerate the deployed Galaxy object hierarchy plus each object's dynamic attributes. ```csharp await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create( new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey, }); bool ok = await repository.TestConnectionAsync(); DateTime? lastDeploy = await repository.GetLastDeployTimeAsync(); IReadOnlyList objects = await repository.DiscoverHierarchyAsync(); foreach (GalaxyObject galaxyObject in objects) { Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})"); foreach (GalaxyAttribute attribute in galaxyObject.Attributes) { Console.WriteLine($" {attribute.AttributeName} -> {attribute.FullTagReference}"); } } ``` Use `DiscoverHierarchyOptions` to request a server-side slice without pulling the full Galaxy: ```csharp IReadOnlyList pumps = await repository.DiscoverHierarchyAsync( new DiscoverHierarchyOptions { RootContainedPath = "Area1/Line3", TagNameGlob = "Pump_*", IncludeAttributes = false, }); ``` The CLI exposes the same operations: ```powershell dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY ``` ### Browsing lazily For UI trees or OPC UA bridges, use `BrowseChildrenAsync` to walk one level at a time instead of paging the full hierarchy. Pass an empty request for root objects; subsequent calls supply `ParentGobjectId`, `ParentTagName`, or `ParentContainedPath`. Each child's `ChildHasChildren[i]` tells you whether to draw an expand triangle. Filter fields match `DiscoverHierarchy`. See [Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full request and filter semantics. ```csharp BrowseChildrenReply roots = await repository.BrowseChildrenAsync( new BrowseChildrenRequest()); for (int i = 0; i < roots.Children.Count; i++) { GalaxyObject child = roots.Children[i]; bool hasChildren = roots.ChildHasChildren[i]; Console.WriteLine($"{child.TagName} expand={hasChildren}"); } ``` #### High-level walker For UI trees, the client provides a `LazyBrowseNode` walker that handles sibling pagination and the `child_has_children` hint for you: ```csharp await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create( new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey }); IReadOnlyList roots = await repository.BrowseAsync(); foreach (LazyBrowseNode root in roots) { if (root.HasChildrenHint) { await root.ExpandAsync(); } foreach (LazyBrowseNode child in root.Children) { Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})"); } } ``` `ExpandAsync` is idempotent — calling it twice fires only one RPC, and is safe under concurrent callers. To refresh after a Galaxy redeploy, call `BrowseAsync` again from the root. The CLI counterpart is `galaxy-browse`. Without `--parent` it walks the root objects and eagerly expands `--depth` further levels into an indented tree; with `--parent ` it fetches exactly one level of children for that object (`--depth` is ignored there). Filter flags map onto `BrowseChildrenOptions`: `--category-ids` and `--template-contains` are comma-separated lists, `--tag-name-glob` / `--alarm-bearing-only` / `--historized-only` are scalar, and `--include-attributes` overrides the server default for attribute population. ```powershell dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-browse --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --depth 1 dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-browse --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --parent 42 --json ``` ### Watching deploy events `WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The server emits a bootstrap event with the current state on subscribe, then one event per new `time_of_last_deploy`. Pass a `lastSeenDeployTime` to suppress the bootstrap when the caller already holds the current deploy time. Use the monotonic `Sequence` field to detect dropped events: gaps mean the per-subscriber server-side buffer overflowed and the caller should reconcile. Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The caller is responsible for reopening the stream on transient failures. ```csharp await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options); DateTimeOffset? lastSeen = null; await foreach (DeployEvent evt in repository.WatchDeployEventsAsync( lastSeen, cancellationToken)) { Console.WriteLine( $"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}"); if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null) { lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset(); } } ``` The CLI counterpart streams events until Ctrl+C (or `--max-events`): ```powershell dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json ``` Use TLS options for a secured gateway: ```powershell dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint https://ZB.MOM.WW.MxGateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name ZB.MOM.WW.MxGateway.example.local --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json ``` ### TLS trust The gateway can auto-generate its own self-signed certificate (it has no PKI), so the client is **lenient by default**: a TLS connection (`UseTls` / `--tls`) with no pinned CA accepts whatever certificate the gateway presents. To verify instead, pin a CA with `CaCertificatePath` / `--ca-file` (this path also enforces the certificate hostname/SAN match), or set `RequireCertificateValidation` to force OS/system-trust verification without pinning. Use `ServerNameOverride` / `--server-name` when the dialed host differs from the certificate SAN. See [Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate). ## Integration Checks Run live checks only when a gateway and MXAccess-backed worker are available: ```powershell $env:MXGATEWAY_INTEGRATION = '1' $env:MXGATEWAY_ENDPOINT = 'http://localhost:5000' $env:MXGATEWAY_API_KEY = '' $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed' dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json ``` ## Installing as a NuGet Package The client publishes to the internal Gitea NuGet feed at `https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`. Add the feed once: ````bash dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \ --name dohertj2-gitea \ --username \ --password \ --store-password-in-clear-text ```` Then add the package to your project: ````bash dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.2.0 ```` The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively. ## Related Documentation - [Client Packaging](../../docs/ClientPackaging.md) - [Client Proto Generation](../../docs/ClientProtoGeneration.md) - [.NET Client Detailed Design](./DotnetClientDesign.md)