9eedf9d6a9
A consuming project hit two MXAccess parity surprises: a plain Write only records its user_id when the item has an active supervisory advise (the path to take when not authenticating), and array writes replace the whole array rather than patching individual elements. Document both across the five client READMEs and gateway.md's compatibility baseline, and expose the missing advise-supervisory subcommand in the go/python/rust/java CLIs (plus the .NET help text) so callers can establish the supervisory advise without dropping to the raw command API.
401 lines
17 KiB
Markdown
401 lines
17 KiB
Markdown
# .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 <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`.
|
|
|
|
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.
|
|
|
|
## 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.
|
|
|
|
## 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`.
|
|
|
|
## 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.1.1
|
|
````
|
|
|
|
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)
|