Files
mxaccessgw/clients/rust/README.md
Joseph Doherty bde042b4d4 feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients
Every parity-critical single-item MXAccess command now has a typed session
helper instead of only a raw-Invoke escape hatch. Added per client:
- Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser,
  ArchestrAUserToId
- Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate
- CLI-30: Unregister (Rust + .NET; Go/Python already had it)

Each wraps the existing raw-command machinery (no new wire surface) and runs the
same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess
parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces
the native failure unchanged (not pre-validated/reordered). Credentials
(AuthenticateUser password, WriteSecured payloads) route through each client's
secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display;
each suite asserts a distinctive credential is absent from surfaced errors. New
CLI subcommands source credentials via flag/env, never echoed.

- .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor.
  Build clean (0 warn), 102 passed.
- Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory
  to typed. gofmt/vet/build/test clean.
- Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on
  secured paths; error.rs credential scrub. fmt/check/test/clippy clean.
- Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands.
  145 passed.
- Shared doc: ClientLibrariesDesign "Typed Command Parity" section.

Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30
stay open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:41:43 -04:00

18 KiB

Rust Client Workspace

The Rust client workspace contains the MXAccess Gateway client library, a test CLI, and tests for generated contract wiring plus wrapper behavior. The library uses the shared protobuf inputs documented in ../../docs/ClientProtoGeneration.md so the Rust bindings compile against the same public gateway and worker contracts as the server.

Layout

clients/rust/
  Cargo.toml
  build.rs
  src/
  tests/
  crates/mxgw-cli/

build.rs reads the .proto files from ../../src/ZB.MOM.WW.MxGateway.Contracts/Protos and generates tonic/prost bindings into Cargo build output. src/generated.rs declares the Rust modules that include those generated files. src/generated remains reserved for checked-in generator output if the crate later changes to source-tree generation.

Build And Test

Run the Rust workspace checks from clients/rust:

cargo fmt --all --check
cargo test --workspace
cargo check --workspace
cargo clippy --workspace --all-targets -- -D warnings

The build script uses protoc from PATH or the Windows path recorded in ../../docs/ToolchainLinks.md.

Packaging

Create local release artifacts from clients/rust:

cargo build --workspace --release
cargo install --path crates/mxgw-cli --locked --force

cargo check --workspace regenerates the tonic and prost modules into Cargo build output through build.rs.

CLI

The CLI exposes version, session, command, event stream, write, and smoke commands over the same client wrapper used by tests:

cargo run -p mxgw-cli -- version --json
cargo run -p mxgw-cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- register --session-id <session-id> --client-name mxgw-rust-cli --json
cargo run -p mxgw-cli -- add-item --session-id <session-id> --server-handle 1 --item TestChildObject.TestInt --json
cargo run -p mxgw-cli -- advise --session-id <session-id> --server-handle 1 --item-handle 1 --json
cargo run -p mxgw-cli -- stream-events --session-id <session-id> --max-events 1 --json
cargo run -p mxgw-cli -- stream-alarms --session-id <session-id> --max-messages 1 --json
cargo run -p mxgw-cli -- acknowledge-alarm --session-id <session-id> --alarm-reference "\\Galaxy\Area001.Pump001.PumpFault" --json
cargo run -p mxgw-cli -- write --session-id <session-id> --server-handle 1 --item-handle 1 --value-type int32 --value 123 --json

Use --tls, --ca-file, and --server-name-override for TLS endpoints. The CLI reads the API key from --api-key or from --api-key-env, which defaults to MXGATEWAY_API_KEY. API keys are redacted by the library option and secret types.

cargo run -p mxgw-cli -- smoke --endpoint https://mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt --json

TLS trust (pin-only)

The gateway can auto-generate its own self-signed certificate (it has no PKI). Unlike the other clients, the Rust client is not lenient: tonic 0.13.1 exposes no public hook to inject a custom certificate verifier, so TLS over Rust cannot accept an arbitrary self-signed certificate. A TLS connection requires one of two trust paths:

  • --ca-file / ClientOptions::with_ca_file(...) to pin a CA (export the gateway's self-signed certificate and pin it). This is the path for a self-signed gateway.
  • --require-certificate-validation / with_require_certificate_validation(true) to verify against the operating system's trust roots (tls-native-roots). This only succeeds for a certificate that chains to a root the host already trusts — i.e. a gateway fronted by a publicly- or enterprise-CA-issued certificate, not a bare self-signed one.

TLS with neither set fails connect with a clear, actionable error rather than accepting the certificate. See Gateway Configuration.

Library Surface

ClientOptions configures endpoint, API key, plaintext or TLS transport, timeouts, custom CA files, and server name override. GatewayClient::connect creates an authenticated tonic client and attaches authorization: Bearer <api-key> metadata to unary and streaming calls.

GatewayClient exposes raw generated calls through open_session_raw, close_session_raw, invoke_raw, stream_events, query_active_alarms, stream_alarms, acknowledge_alarm, and raw_client. stream_alarms returns an AlarmFeedStream async stream of alarm-feed messages and shares the gateway's central alarm monitor with every other client. The session helpers keep MXAccess handles visible:

let session = client.open_session(request).await?;
let server_handle = session.register("mxgw-rust").await?;
let item_handle = session.add_item(server_handle, "TestChildObject.TestInt").await?;
session.advise(server_handle, item_handle).await?;
let mut events = session.events().await?;
session.close().await?;

MxValue, MxArrayValue, and MxStatus wrap generated protobuf messages while preserving the raw message for parity diagnostics. Command replies whose protocol status is not PROTOCOL_STATUS_CODE_OK become Error::Command and retain the raw MxCommandReply.

The typed command helpers (register, add_item, write, the bulk variants, etc.) also enforce MXAccess parity on an otherwise-OK reply: a reply that reports a negative hresult (COM failure semantics — a positive code such as S_FALSE = 1 is a success) or a non-success MXSTATUS_PROXY status entry becomes Error::MxAccess, which boxes an MxAccessError retaining the raw MxCommandReply (recover it with MxAccessError::reply / into_reply). Its message summarizes the hresult and status entries with credential-safe redaction. Per-item bulk failures are reported inside each result entry (was_successful = false) and do not raise Error::MxAccess. The raw invoke_raw / client.invoke_raw escape hatch performs neither check and returns the unvalidated reply.

Event Streaming And Reconnect-Replay Gaps

session.events() / session.events_after(after_worker_sequence) (and the lower-level client.stream_events) return an EventStream that yields EventItem values, not bare MxEvents:

use zb_mom_ww_mxgateway_client::EventItem;

let mut stream = session.events_after(cursor).await?;
while let Some(item) = stream.next().await {
    match item? {
        EventItem::Event(event) => { /* apply the MXAccess change */ }
        EventItem::ReplayGap(gap) => {
            // Recent history was evicted — discard local state and re-snapshot,
            // then resume without provoking another gap:
            let resume = gap.oldest_available_sequence.saturating_sub(1);
            stream = session.events_after(resume).await?;
        }
    }
}

Almost every item is a normal EventItem::Event. EventItem::ReplayGap is a faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the client does not synthesize it. The gateway emits the sentinel at most once, at the head of a stream resumed via events_after (after_worker_sequence) when the requested sequence is older than the oldest event still retained in the session's replay ring: events in the open interval (requested_after_sequence, oldest_available_sequence) were evicted and cannot be replayed. A ReplayGap therefore means "you missed events — discard any local state and re-snapshot." To resume without a second gap, reconnect with events_after(gap.oldest_available_sequence - 1), which replays starting at the first still-retained event. A stream opened from the beginning (session.events() / events_after(0)) never produces a ReplayGap.

EventItem provides as_event(), into_event(), and replay_gap() accessors for callers that prefer not to match. The mxgw-cli stream-events subcommand renders the sentinel as a distinct REPLAY_GAP … line (or a replayGap JSON object under --json / --jsonl).

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 authenticate_user

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 (authenticate_userwrite_secured/write_secured2) 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 user_id on a plain write is ignored.

The session exposes a typed advise_supervisory helper alongside advise/un_advise:

session.advise_supervisory(server_handle, item_handle).await?;
session.write(server_handle, item_handle, value, user_id).await?;

The CLI exposes the same command as advise-supervisory, and write / write2 take --user-id.

Verified / secured writes and user resolution

The verified path has typed session helpers too: authenticate_user (returns the resolved MXAccess user id), archestra_user_to_id, and write_secured / write_secured2. MXAccess parity is preserved — a write_secured issued before the required authenticate_user + advise_supervisory (or before a value-bearing body) fails natively and the failure surfaces as Error::MxAccess; it is not smoothed over. Credentials passed to authenticate_user (and secured write payloads) are placed only on the wire — the client never logs them and never embeds them in an Error's Display/Debug; the only error text that can surface (from tonic::Status messages and reply diagnostics) is scrubbed by the credential-redaction seam. The CLI mirrors these as authenticate-user (password via --password or the --password-env env var, never echoed) and write-secured.

The remaining single-item command helpers round out MXAccess parity: unregister, suspend / activate (each returns the operation's MxStatus), add_buffered_item, and set_buffered_update_interval.

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.

Default-fill partial array writes

When you only need to set a handful of indices and want every other position to take the element type's default (zero / false / empty string / Unix epoch for timestamps), use Session::write_array_elements instead:

// Write a 10-element integer array; index 0 = 42, index 7 = 99,
// all other indices default to 0 (not preserved from the previous value).
session
    .write_array_elements(
        server_handle,
        item_handle,
        MxDataType::Integer,
        10,
        [(0, MxValue::int32(42)), (7, MxValue::int32(99))],
        user_id,
    )
    .await?;

The gateway expands the sparse representation into a full MxArray before forwarding to the worker — the worker and MXAccess COM never see the sparse form. Unmentioned indices are reset to the type default, not preserved from the existing attribute value.

Bare-name array AddItem normalisation

Adding a bare array attribute name (e.g. Tank01.Temperature) via AddItem, AddItem2, AddItemBulk, or AddBufferedItem is automatically normalised to Tank01.Temperature[] by the gateway so the worker can resolve the full array. You do not need to append [] in client code; the gateway handles it.

Galaxy Repository browse

The Galaxy Repository service exposes a read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL database). It uses the same API-key auth as the gateway service but requires the metadata:read scope on the server.

GalaxyClient wraps the generated Galaxy bindings the same way GatewayClient wraps the gateway bindings:

let mut galaxy = GalaxyClient::connect(
    ClientOptions::new("http://localhost:5000")
        .with_api_key(ApiKey::new(api_key)),
).await?;

let ok = galaxy.test_connection().await?;
let last_deploy = galaxy.get_last_deploy_time().await?; // Option<prost_types::Timestamp>
let objects = galaxy.discover_hierarchy().await?;       // Vec<GalaxyObject>

get_last_deploy_time returns None when the server reports present = false. discover_hierarchy returns the generated GalaxyObject proto type (re-exported via zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1) with all attributes attached.

The CLI ships matching subcommands under galaxy:

cargo run -p mxgw-cli -- galaxy test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json

Browsing lazily

For UI trees or OPC UA bridges, use browse_children_raw to walk one level at a time instead of paging the full hierarchy. Pass a default request for root objects; subsequent calls set parent_gobject_id, parent_tag_name, or parent_contained_path. Filter fields match discover_hierarchy. Each response pairs children with child_has_children so you know which nodes to expand. See Galaxy Repository for full request and filter semantics.

use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;

let reply = galaxy.browse_children_raw(BrowseChildrenRequest::default()).await?;
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
    println!("{} expand={}", child.tag_name, has_children);
}

High-level walker

For UI trees, the client provides a LazyBrowseNode walker that handles sibling pagination and the child_has_children hint for you:

let mut client = GalaxyClient::connect(
    ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new(api_key)),
).await?;
let roots = client.browse(None).await?;
for root in &roots {
    if root.has_children_hint() {
        root.expand().await?;
    }
    for child in root.children().await {
        let kind = if child.has_children_hint() { "has children" } else { "leaf" };
        println!("{} ({kind})", child.object().tag_name);
    }
}

expand is idempotent — calling it twice fires only one RPC, and is safe under concurrent callers. To refresh after a Galaxy redeploy, call browse again from the root.

Watching deploy events

watch_deploy_events opens the WatchDeployEvents server stream. The server emits a bootstrap DeployEvent describing the current cache state on subscribe, then one event each time the cached galaxy.time_of_last_deploy changes. sequence is monotonic per server start; gaps signal that the per-subscriber buffer dropped older events. Pass last_seen_deploy_time to suppress the bootstrap event when the client's cached deploy time matches the server's.

use futures_util::StreamExt;

let mut stream = galaxy.watch_deploy_events(None).await?;
while let Some(event) = stream.next().await {
    let event = event?;
    println!(
        "seq={} objects={} attributes={}",
        event.sequence, event.object_count, event.attribute_count,
    );
}
// Drop the stream to cancel the gRPC call.

The matching CLI subcommand prints one line per event (--json switches to one JSON object per event). --last-seen-deploy-time accepts an RFC3339 timestamp and is forwarded to the server. --max-events (default 0 = no cap) lets you stop after a fixed number of events; otherwise the command runs until the stream ends or Ctrl+C is pressed.

cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T15:30:00Z

Integration Checks

Run live checks only when a gateway and MXAccess-backed worker are available:

$env:MXGATEWAY_INTEGRATION = '1'
$env:MXGATEWAY_ENDPOINT = 'http://127.0.0.1:5000'
$env:MXGATEWAY_API_KEY = '<gateway-api-key>'
$env:MXGATEWAY_TEST_ITEM = 'TestChildObject.TestInt'
cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json

Installing from the Gitea Cargo registry

The crate publishes to the internal Gitea Cargo registry. Register the registry once in your global ~/.cargo/config.toml:

[registries.dohertj2-gitea]
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"

Authentication: cargo reads credentials from ~/.cargo/credentials.toml:

[registries.dohertj2-gitea]
token = "Bearer <your-gitea-token>"

Then add the dependency:

[dependencies]
zb-mom-ww-mxgateway-client = { version = "0.1.1", registry = "dohertj2-gitea" }