# .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
```

Most tests substitute `FakeGatewayTransport` for `IMxGatewayClientTransport`, so
they never touch the wire. `MxGatewayClientWireTests` is the exception: it drives
the ordinary public API against `WireFakeGatewayServer`, a real gRPC server
(Kestrel h2c on an ephemeral loopback port) serving
`MxAccessGateway.MxAccessGatewayBase`. Only the gateway's behaviour is canned —
the HTTP/2 framing, protobuf serialization, `authorization` metadata, and gRPC
status codes are genuine, so it catches decode and metadata breaks a transport
fake cannot see. No MXAccess or worker is involved; it runs in the default suite.
See `docs/GatewayTesting.md` (Client Wire Tests) for the cross-client pattern.

```powershell
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter FullyQualifiedName~MxGatewayClientWireTests
```

## 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 <api-key>`.
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<MxEvent>` 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<uint, MxValue>
    {
        [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 <id> --client-name mxgw-dotnet-cli --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- add-item --session-id <id> --server-handle 1 --item Area001.Pump001.Speed --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- advise --session-id <id> --server-handle 1 --item-handle 1 --json
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- write --session-id <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 <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 <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 = "<verify-user password>"
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- authenticate-user --session-id <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<GalaxyObject> 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<GalaxyObject> 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<LazyBrowseNode> 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 <gobject-id>` 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 = '<gateway-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 <gitea-username> \
    --password <gitea-token-or-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)
