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.
2700 lines
94 KiB
Rust
2700 lines
94 KiB
Rust
//! `mxgw` — the Rust test CLI for the MXAccess Gateway.
|
|
//!
|
|
//! The binary wraps [`zb_mom_ww_mxgateway_client`] in a `clap`-driven command surface
|
|
//! used by the cross-language smoke matrix and by developers exercising the
|
|
//! gateway by hand. Every subcommand mirrors a single gateway/Galaxy RPC,
|
|
//! prints either a terse line or a JSON document with `--json`, and exits
|
|
//! non-zero on any failure.
|
|
|
|
#![warn(missing_docs)]
|
|
|
|
use std::env;
|
|
use std::io::{self, BufRead, Write};
|
|
use std::path::PathBuf;
|
|
use std::process::ExitCode;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
|
use futures_util::StreamExt;
|
|
use serde_json::json;
|
|
use serde_json::Value;
|
|
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
|
|
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
|
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
|
alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage,
|
|
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
|
|
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest,
|
|
StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
|
|
WriteSecuredBulkEntry,
|
|
};
|
|
use zb_mom_ww_mxgateway_client::{
|
|
next_correlation_id, ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue,
|
|
MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
|
|
};
|
|
|
|
const MAX_AGGREGATE_EVENTS: usize = 10_000;
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(name = "mxgw")]
|
|
#[command(about = "MXAccess Gateway Rust test CLI")]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum Command {
|
|
Version {
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
Ping {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long, default_value = "ping")]
|
|
message: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
OpenSession {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long, default_value = "mxgw-rust-cli")]
|
|
client_name: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
CloseSession {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
Register {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long, default_value = "mxgw-rust-cli")]
|
|
client_name: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
AddItem {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long)]
|
|
item: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
Advise {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long)]
|
|
item_handle: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
AdviseSupervisory {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long)]
|
|
item_handle: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
SubscribeBulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long, value_delimiter = ',')]
|
|
items: Vec<String>,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
UnsubscribeBulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long, value_delimiter = ',')]
|
|
item_handles: Vec<i32>,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Snapshot the current value for each requested tag. Cached
|
|
/// OnDataChange values are returned for tags that are already advised
|
|
/// without touching the existing subscription; otherwise the worker
|
|
/// takes a one-shot AddItem + Advise + UnAdvise + RemoveItem lifecycle.
|
|
ReadBulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long, value_delimiter = ',')]
|
|
items: Vec<String>,
|
|
/// Per-tag snapshot timeout in milliseconds. `0` uses the worker default (1000 ms).
|
|
#[arg(long, default_value_t = 0)]
|
|
timeout_ms: u32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Bulk Write — one MXAccess Write per (item_handle, value) pair.
|
|
WriteBulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long, value_delimiter = ',')]
|
|
item_handles: Vec<i32>,
|
|
#[arg(long, value_enum)]
|
|
value_type: CliValueType,
|
|
#[arg(long, value_delimiter = ',')]
|
|
values: Vec<String>,
|
|
#[arg(long, default_value_t = 0)]
|
|
user_id: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Bulk Write2 — timestamped variant; the timestamp applies to all entries.
|
|
Write2Bulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long, value_delimiter = ',')]
|
|
item_handles: Vec<i32>,
|
|
#[arg(long, value_enum)]
|
|
value_type: CliValueType,
|
|
#[arg(long, value_delimiter = ',')]
|
|
values: Vec<String>,
|
|
#[arg(long)]
|
|
timestamp: String,
|
|
#[arg(long, default_value_t = 0)]
|
|
user_id: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Bulk WriteSecured.
|
|
WriteSecuredBulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long, value_delimiter = ',')]
|
|
item_handles: Vec<i32>,
|
|
#[arg(long, value_enum)]
|
|
value_type: CliValueType,
|
|
#[arg(long, value_delimiter = ',')]
|
|
values: Vec<String>,
|
|
#[arg(long, default_value_t = 0)]
|
|
current_user_id: i32,
|
|
#[arg(long, default_value_t = 0)]
|
|
verifier_user_id: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Bulk WriteSecured2 — timestamped + verified.
|
|
WriteSecured2Bulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long, value_delimiter = ',')]
|
|
item_handles: Vec<i32>,
|
|
#[arg(long, value_enum)]
|
|
value_type: CliValueType,
|
|
#[arg(long, value_delimiter = ',')]
|
|
values: Vec<String>,
|
|
#[arg(long)]
|
|
timestamp: String,
|
|
#[arg(long, default_value_t = 0)]
|
|
current_user_id: i32,
|
|
#[arg(long, default_value_t = 0)]
|
|
verifier_user_id: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Cross-language stress benchmark for ReadBulk: opens its own session,
|
|
/// subscribes to `--bulk-size` tags so the worker's per-session value cache
|
|
/// populates from real OnDataChange events, then hammers ReadBulk in a
|
|
/// tight loop for `--duration-seconds` with per-call latency capture. Emits
|
|
/// a single JSON object on stdout matching the schema the
|
|
/// `scripts/bench-read-bulk.ps1` driver collates across all five clients.
|
|
BenchReadBulk {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long, default_value = "mxgw-rust-bench")]
|
|
client_name: String,
|
|
#[arg(long, default_value_t = 30)]
|
|
duration_seconds: u64,
|
|
#[arg(long, default_value_t = 3)]
|
|
warmup_seconds: u64,
|
|
#[arg(long, default_value_t = 6)]
|
|
bulk_size: usize,
|
|
#[arg(long, default_value_t = 1)]
|
|
tag_start: i32,
|
|
#[arg(long, default_value = "TestMachine_")]
|
|
tag_prefix: String,
|
|
#[arg(long, default_value = "TestChangingInt")]
|
|
tag_attribute: String,
|
|
#[arg(long, default_value_t = 1500)]
|
|
timeout_ms: u32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
StreamEvents {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long, default_value_t = 0)]
|
|
after_worker_sequence: u64,
|
|
#[arg(long, default_value_t = 1)]
|
|
max_events: usize,
|
|
#[arg(long)]
|
|
json: bool,
|
|
#[arg(long)]
|
|
jsonl: bool,
|
|
},
|
|
/// Attach to the gateway's session-less central alarm feed. The stream
|
|
/// opens with one `active_alarm` per currently-active alarm, then a
|
|
/// single `snapshot_complete`, then a `transition` for every subsequent
|
|
/// raise / acknowledge / clear.
|
|
StreamAlarms {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
/// Optional alarm-reference prefix scoping the feed to an equipment
|
|
/// sub-tree. Omit to stream every active alarm.
|
|
#[arg(long)]
|
|
filter_prefix: Option<String>,
|
|
#[arg(long, default_value_t = 1)]
|
|
max_events: usize,
|
|
#[arg(long)]
|
|
json: bool,
|
|
#[arg(long)]
|
|
jsonl: bool,
|
|
},
|
|
/// Acknowledge an active MXAccess alarm condition through the gateway's
|
|
/// session-less AcknowledgeAlarm RPC.
|
|
AcknowledgeAlarm {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
reference: String,
|
|
#[arg(long, default_value = "")]
|
|
comment: String,
|
|
#[arg(long, default_value = "")]
|
|
operator: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
Write {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long)]
|
|
item_handle: i32,
|
|
#[arg(long, value_enum)]
|
|
value_type: CliValueType,
|
|
#[arg(long)]
|
|
value: String,
|
|
#[arg(long, default_value_t = 0)]
|
|
user_id: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
Write2 {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
session_id: String,
|
|
#[arg(long)]
|
|
server_handle: i32,
|
|
#[arg(long)]
|
|
item_handle: i32,
|
|
#[arg(long, value_enum)]
|
|
value_type: CliValueType,
|
|
#[arg(long)]
|
|
value: String,
|
|
#[arg(long)]
|
|
timestamp: String,
|
|
#[arg(long, default_value_t = 0)]
|
|
user_id: i32,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
Smoke {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
item: String,
|
|
#[arg(long, default_value = "mxgw-rust-smoke")]
|
|
client_name: String,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Read commands from stdin, one per line, execute each in sequence, and
|
|
/// write `__MXGW_BATCH_EOR__` to stdout after every result. Errors are
|
|
/// written as `{"error":"…","type":"error"}` JSON to stdout (not stderr)
|
|
/// so the harness can parse them without interleaving stderr. The loop
|
|
/// never terminates on command error or accidental blank lines; only
|
|
/// stdin EOF ends the session — empty lines log an empty-EOR-bracketed
|
|
/// result and continue, matching the other four language CLIs.
|
|
Batch,
|
|
#[command(subcommand)]
|
|
Galaxy(GalaxyCommand),
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
enum GalaxyCommand {
|
|
TestConnection {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
LastDeployTime {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
DiscoverHierarchy {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Lazily browse the Galaxy hierarchy through `BrowseChildren`.
|
|
///
|
|
/// With no `--parent-gobject-id` the root objects are listed; pass a
|
|
/// parent id to list that object's direct children. `--depth` controls
|
|
/// how many further levels are eagerly expanded (0 = the requested level
|
|
/// only). The filter flags map onto `BrowseChildrenOptions` and are reused
|
|
/// at every expanded level, mirroring the lazy-browse library helper.
|
|
Browse {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
/// Parent gobject id whose children to browse. Omit for root objects.
|
|
#[arg(long)]
|
|
parent_gobject_id: Option<i32>,
|
|
/// Restrict to objects whose `category_id` matches one of these ids.
|
|
/// Repeatable.
|
|
#[arg(long = "category-id")]
|
|
category_ids: Vec<i32>,
|
|
/// Restrict to objects whose template chain contains this entry.
|
|
/// Repeatable (combined with AND).
|
|
#[arg(long = "template-contains")]
|
|
template_chain_contains: Vec<String>,
|
|
/// Restrict to objects whose tag name matches this SQL `LIKE`-style glob.
|
|
#[arg(long)]
|
|
tag_name_glob: Option<String>,
|
|
/// Populate `attributes` on the returned objects.
|
|
#[arg(long)]
|
|
include_attributes: bool,
|
|
/// Only return objects that own at least one alarm-bearing attribute.
|
|
#[arg(long)]
|
|
alarm_bearing_only: bool,
|
|
/// Only return objects that own at least one historized attribute.
|
|
#[arg(long)]
|
|
historized_only: bool,
|
|
/// Number of additional levels to eagerly expand beneath each returned
|
|
/// node. 0 (the default) prints only the requested level.
|
|
/// Ignored when --parent-gobject-id is specified.
|
|
#[arg(long, default_value_t = 0)]
|
|
depth: usize,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Subscribe to the WatchDeployEvents server stream.
|
|
///
|
|
/// Prints one line per received event (or one JSON object with `--json`).
|
|
/// Runs until the stream ends, the server fails the call, or the
|
|
/// process is interrupted (Ctrl+C).
|
|
#[command(alias = "watch-deploy-events")]
|
|
Watch {
|
|
#[command(flatten)]
|
|
connection: ConnectionArgs,
|
|
/// Optional RFC3339 timestamp (e.g. `2026-04-28T15:30:00Z`). When
|
|
/// supplied, the server suppresses the bootstrap event if its
|
|
/// cached deploy time matches this value.
|
|
#[arg(long)]
|
|
last_seen_deploy_time: Option<String>,
|
|
/// Optional cap on the number of events to print before exiting.
|
|
/// 0 (the default) means run until cancelled or the stream ends.
|
|
#[arg(long, default_value_t = 0)]
|
|
max_events: usize,
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Args, Clone)]
|
|
struct ConnectionArgs {
|
|
#[arg(long, default_value = "http://127.0.0.1:5000")]
|
|
endpoint: String,
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
/// Name of the environment variable holding the gateway API key. The
|
|
/// variable's value must be a full gateway key of the form
|
|
/// `mxgw_<key-id>_<secret>`; it is forwarded verbatim as the Bearer
|
|
/// token, so do not point this at an unrelated credential.
|
|
#[arg(long, default_value = "MXGATEWAY_API_KEY")]
|
|
api_key_env: String,
|
|
/// Use an unencrypted (plaintext h2c) channel. Mutually exclusive with
|
|
/// `--tls`; supplying both is rejected so an explicit `--tls` cannot be
|
|
/// silently downgraded.
|
|
#[arg(long, conflicts_with = "tls")]
|
|
plaintext: bool,
|
|
#[arg(long)]
|
|
tls: bool,
|
|
#[arg(long)]
|
|
ca_file: Option<PathBuf>,
|
|
#[arg(long)]
|
|
server_name_override: Option<String>,
|
|
/// Verify the server certificate against the system trust roots even
|
|
/// without a pinned CA. The Rust client's default is to require a CA
|
|
/// file (see `--ca-file`); set this flag to use system roots instead.
|
|
#[arg(long)]
|
|
require_certificate_validation: bool,
|
|
#[arg(long, default_value_t = 10)]
|
|
connect_timeout_seconds: u64,
|
|
#[arg(long, default_value_t = 30)]
|
|
call_timeout_seconds: u64,
|
|
}
|
|
|
|
impl ConnectionArgs {
|
|
fn options(&self) -> ClientOptions {
|
|
let mut options = ClientOptions::new(self.endpoint.clone())
|
|
.with_plaintext(!self.tls || self.plaintext)
|
|
.with_connect_timeout(Duration::from_secs(self.connect_timeout_seconds))
|
|
.with_call_timeout(Duration::from_secs(self.call_timeout_seconds));
|
|
|
|
if let Some(api_key) = self
|
|
.api_key
|
|
.clone()
|
|
.or_else(|| env::var(&self.api_key_env).ok())
|
|
.filter(|value| !value.is_empty())
|
|
{
|
|
options = options.with_api_key(ApiKey::new(api_key));
|
|
}
|
|
if let Some(ca_file) = &self.ca_file {
|
|
options = options.with_ca_file(ca_file);
|
|
}
|
|
if let Some(server_name_override) = &self.server_name_override {
|
|
options = options.with_server_name_override(server_name_override);
|
|
}
|
|
if self.require_certificate_validation {
|
|
options = options.with_require_certificate_validation(true);
|
|
}
|
|
|
|
options
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, ValueEnum)]
|
|
enum CliValueType {
|
|
Bool,
|
|
Int32,
|
|
Int64,
|
|
Float,
|
|
Double,
|
|
String,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> ExitCode {
|
|
let cli = Cli::parse();
|
|
let result = match cli.command {
|
|
Command::Batch => run_batch().await,
|
|
command => dispatch(command).await,
|
|
};
|
|
match result {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(error) => {
|
|
eprintln!("{error}");
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dispatch a parsed [`Command`] to its handler. All subcommands except
|
|
/// [`Command::Batch`] are handled here; `Batch` is handled separately in
|
|
/// `main` to avoid mutual recursion between `dispatch` and `run_batch`.
|
|
async fn dispatch(command: Command) -> Result<(), Error> {
|
|
match command {
|
|
Command::Batch => {
|
|
return Err(Error::InvalidArgument {
|
|
name: "batch".to_owned(),
|
|
detail: "batch cannot be nested inside another batch session".to_owned(),
|
|
});
|
|
}
|
|
Command::Version { json } => print_version(json),
|
|
Command::Ping {
|
|
connection,
|
|
message,
|
|
json,
|
|
} => {
|
|
let client = connect(connection).await?;
|
|
let reply = client
|
|
.invoke(MxCommandRequest {
|
|
client_correlation_id: next_correlation_id("cli-ping"),
|
|
command: Some(MxCommand {
|
|
kind: MxCommandKind::Ping as i32,
|
|
payload: Some(zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::Ping(
|
|
PingCommand { message },
|
|
)),
|
|
}),
|
|
..MxCommandRequest::default()
|
|
})
|
|
.await?;
|
|
print_command_reply("ping", &reply, json);
|
|
}
|
|
Command::OpenSession {
|
|
connection,
|
|
client_name,
|
|
json,
|
|
} => {
|
|
let client = connect(connection).await?;
|
|
let reply = client
|
|
.open_session_raw(OpenSessionRequest {
|
|
client_session_name: client_name,
|
|
..OpenSessionRequest::default()
|
|
})
|
|
.await?;
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"sessionId": reply.session_id,
|
|
"backendName": reply.backend_name,
|
|
"gatewayProtocolVersion": reply.gateway_protocol_version,
|
|
"workerProtocolVersion": reply.worker_protocol_version,
|
|
})
|
|
);
|
|
} else {
|
|
println!("{}", reply.session_id);
|
|
}
|
|
}
|
|
Command::CloseSession {
|
|
connection,
|
|
session_id,
|
|
json,
|
|
} => {
|
|
let client = connect(connection).await?;
|
|
let reply = client
|
|
.close_session_raw(CloseSessionRequest {
|
|
session_id,
|
|
client_correlation_id: next_correlation_id("cli-close-session"),
|
|
})
|
|
.await?;
|
|
if json {
|
|
println!("{}", json!({ "sessionId": reply.session_id }));
|
|
} else {
|
|
println!("closed {}", reply.session_id);
|
|
}
|
|
}
|
|
Command::Register {
|
|
connection,
|
|
session_id,
|
|
client_name,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
let server_handle = session.register(&client_name).await?;
|
|
print_handle("serverHandle", server_handle, json);
|
|
}
|
|
Command::AddItem {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
let item_handle = session.add_item(server_handle, &item).await?;
|
|
print_handle("itemHandle", item_handle, json);
|
|
}
|
|
Command::Advise {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handle,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
session.advise(server_handle, item_handle).await?;
|
|
print_ok("advise", json);
|
|
}
|
|
Command::AdviseSupervisory {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handle,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
session
|
|
.invoke(
|
|
MxCommandKind::AdviseSupervisory,
|
|
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
|
|
AdviseSupervisoryCommand {
|
|
server_handle,
|
|
item_handle,
|
|
},
|
|
),
|
|
)
|
|
.await?;
|
|
print_ok("advise-supervisory", json);
|
|
}
|
|
Command::SubscribeBulk {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
items,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
let results = session.subscribe_bulk(server_handle, items).await?;
|
|
print_bulk_results("subscribe-bulk", &results, json);
|
|
}
|
|
Command::UnsubscribeBulk {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handles,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
let results = session
|
|
.unsubscribe_bulk(server_handle, item_handles)
|
|
.await?;
|
|
print_bulk_results("unsubscribe-bulk", &results, json);
|
|
}
|
|
Command::ReadBulk {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
items,
|
|
timeout_ms,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
let results = session.read_bulk(server_handle, &items, timeout_ms).await?;
|
|
print_read_bulk_results("read-bulk", &results, json);
|
|
}
|
|
Command::WriteBulk {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handles,
|
|
value_type,
|
|
values,
|
|
user_id,
|
|
json,
|
|
} => {
|
|
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
|
|
let session = session_for(connection, session_id).await?;
|
|
let results = session
|
|
.write_bulk(
|
|
server_handle,
|
|
entries
|
|
.into_iter()
|
|
.map(|(item_handle, value)| WriteBulkEntry {
|
|
item_handle,
|
|
value: Some(value),
|
|
user_id,
|
|
})
|
|
.collect(),
|
|
)
|
|
.await?;
|
|
print_write_bulk_results("write-bulk", &results, json);
|
|
}
|
|
Command::Write2Bulk {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handles,
|
|
value_type,
|
|
values,
|
|
timestamp,
|
|
user_id,
|
|
json,
|
|
} => {
|
|
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
|
|
let timestamp_value: ProtoMxValue = MxValue::string(timestamp).into_proto();
|
|
let session = session_for(connection, session_id).await?;
|
|
let results = session
|
|
.write2_bulk(
|
|
server_handle,
|
|
entries
|
|
.into_iter()
|
|
.map(|(item_handle, value)| Write2BulkEntry {
|
|
item_handle,
|
|
value: Some(value),
|
|
timestamp_value: Some(timestamp_value.clone()),
|
|
user_id,
|
|
})
|
|
.collect(),
|
|
)
|
|
.await?;
|
|
print_write_bulk_results("write2-bulk", &results, json);
|
|
}
|
|
Command::WriteSecuredBulk {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handles,
|
|
value_type,
|
|
values,
|
|
current_user_id,
|
|
verifier_user_id,
|
|
json,
|
|
} => {
|
|
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
|
|
let session = session_for(connection, session_id).await?;
|
|
let results = session
|
|
.write_secured_bulk(
|
|
server_handle,
|
|
entries
|
|
.into_iter()
|
|
.map(|(item_handle, value)| WriteSecuredBulkEntry {
|
|
item_handle,
|
|
value: Some(value),
|
|
current_user_id,
|
|
verifier_user_id,
|
|
})
|
|
.collect(),
|
|
)
|
|
.await?;
|
|
print_write_bulk_results("write-secured-bulk", &results, json);
|
|
}
|
|
Command::WriteSecured2Bulk {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handles,
|
|
value_type,
|
|
values,
|
|
timestamp,
|
|
current_user_id,
|
|
verifier_user_id,
|
|
json,
|
|
} => {
|
|
let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
|
|
let timestamp_value: ProtoMxValue = MxValue::string(timestamp).into_proto();
|
|
let session = session_for(connection, session_id).await?;
|
|
let results = session
|
|
.write_secured2_bulk(
|
|
server_handle,
|
|
entries
|
|
.into_iter()
|
|
.map(|(item_handle, value)| WriteSecured2BulkEntry {
|
|
item_handle,
|
|
value: Some(value),
|
|
timestamp_value: Some(timestamp_value.clone()),
|
|
current_user_id,
|
|
verifier_user_id,
|
|
})
|
|
.collect(),
|
|
)
|
|
.await?;
|
|
print_write_bulk_results("write-secured2-bulk", &results, json);
|
|
}
|
|
Command::BenchReadBulk {
|
|
connection,
|
|
client_name,
|
|
duration_seconds,
|
|
warmup_seconds,
|
|
bulk_size,
|
|
tag_start,
|
|
tag_prefix,
|
|
tag_attribute,
|
|
timeout_ms,
|
|
json,
|
|
} => {
|
|
run_bench_read_bulk(
|
|
connection,
|
|
client_name,
|
|
duration_seconds,
|
|
warmup_seconds,
|
|
bulk_size,
|
|
tag_start,
|
|
tag_prefix,
|
|
tag_attribute,
|
|
timeout_ms,
|
|
json,
|
|
)
|
|
.await?;
|
|
}
|
|
Command::StreamEvents {
|
|
connection,
|
|
session_id,
|
|
after_worker_sequence,
|
|
max_events,
|
|
json,
|
|
jsonl,
|
|
} => {
|
|
if max_events > MAX_AGGREGATE_EVENTS {
|
|
return Err(Error::InvalidArgument {
|
|
name: "max-events".to_owned(),
|
|
detail: format!("must be less than or equal to {MAX_AGGREGATE_EVENTS}"),
|
|
});
|
|
}
|
|
|
|
let client = connect(connection).await?;
|
|
let mut stream = client
|
|
.stream_events(StreamEventsRequest {
|
|
session_id,
|
|
after_worker_sequence,
|
|
})
|
|
.await?;
|
|
let mut events: Vec<Value> = Vec::new();
|
|
let mut event_count = 0usize;
|
|
while event_count < max_events {
|
|
let Some(event) = stream.next().await else {
|
|
break;
|
|
};
|
|
let event = event?;
|
|
event_count += 1;
|
|
if jsonl {
|
|
println!("{}", event_to_json(&event));
|
|
} else if json {
|
|
events.push(event_to_json(&event));
|
|
} else {
|
|
println!("{} {}", event.worker_sequence, event.family);
|
|
}
|
|
}
|
|
if json {
|
|
// `eventCount` is preserved for back-compat; `events` carries
|
|
// the per-event detail the cross-language e2e matrix compares.
|
|
println!("{}", json!({ "eventCount": event_count, "events": events }));
|
|
}
|
|
}
|
|
Command::StreamAlarms {
|
|
connection,
|
|
filter_prefix,
|
|
max_events,
|
|
json,
|
|
jsonl,
|
|
} => {
|
|
if max_events > MAX_AGGREGATE_EVENTS {
|
|
return Err(Error::InvalidArgument {
|
|
name: "max-events".to_owned(),
|
|
detail: format!("must be less than or equal to {MAX_AGGREGATE_EVENTS}"),
|
|
});
|
|
}
|
|
|
|
let client = connect(connection).await?;
|
|
let mut stream = client
|
|
.stream_alarms(StreamAlarmsRequest {
|
|
client_correlation_id: next_correlation_id("cli-stream-alarms"),
|
|
alarm_filter_prefix: filter_prefix.unwrap_or_default(),
|
|
})
|
|
.await?;
|
|
let mut messages: Vec<Value> = Vec::new();
|
|
let mut message_count = 0usize;
|
|
while message_count < max_events {
|
|
let Some(message) = stream.next().await else {
|
|
break;
|
|
};
|
|
let message = message?;
|
|
message_count += 1;
|
|
if jsonl {
|
|
println!("{}", alarm_feed_message_to_json(&message));
|
|
} else if json {
|
|
messages.push(alarm_feed_message_to_json(&message));
|
|
} else {
|
|
println!("{}", alarm_feed_message_summary(&message));
|
|
}
|
|
}
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
json!({ "messageCount": message_count, "messages": messages })
|
|
);
|
|
}
|
|
}
|
|
Command::AcknowledgeAlarm {
|
|
connection,
|
|
reference,
|
|
comment,
|
|
operator,
|
|
json,
|
|
} => {
|
|
let client = connect(connection).await?;
|
|
let reply = client
|
|
.acknowledge_alarm(AcknowledgeAlarmRequest {
|
|
client_correlation_id: next_correlation_id("cli-acknowledge-alarm"),
|
|
alarm_full_reference: reference,
|
|
comment,
|
|
operator_user: operator,
|
|
})
|
|
.await?;
|
|
print_acknowledge_alarm_reply(&reply, json);
|
|
}
|
|
Command::Write {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handle,
|
|
value_type,
|
|
value,
|
|
user_id,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
session
|
|
.write(
|
|
server_handle,
|
|
item_handle,
|
|
parse_value(value_type, &value)?,
|
|
user_id,
|
|
)
|
|
.await?;
|
|
print_ok("write", json);
|
|
}
|
|
Command::Write2 {
|
|
connection,
|
|
session_id,
|
|
server_handle,
|
|
item_handle,
|
|
value_type,
|
|
value,
|
|
timestamp,
|
|
user_id,
|
|
json,
|
|
} => {
|
|
let session = session_for(connection, session_id).await?;
|
|
session
|
|
.write2(
|
|
server_handle,
|
|
item_handle,
|
|
parse_value(value_type, &value)?,
|
|
MxValue::string(timestamp),
|
|
user_id,
|
|
)
|
|
.await?;
|
|
print_ok("write2", json);
|
|
}
|
|
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
|
|
Command::Smoke {
|
|
connection,
|
|
item,
|
|
client_name,
|
|
json,
|
|
} => {
|
|
let client = connect(connection).await?;
|
|
let session = client
|
|
.open_session(OpenSessionRequest {
|
|
client_session_name: client_name.clone(),
|
|
..OpenSessionRequest::default()
|
|
})
|
|
.await?;
|
|
let result = async {
|
|
let server_handle = session.register(&client_name).await?;
|
|
let item_handle = session.add_item(server_handle, &item).await?;
|
|
session.advise(server_handle, item_handle).await?;
|
|
Ok::<_, Error>((server_handle, item_handle))
|
|
}
|
|
.await;
|
|
let close_result = session.close().await;
|
|
let (server_handle, item_handle) = result?;
|
|
close_result?;
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"sessionId": session.id(),
|
|
"serverHandle": server_handle,
|
|
"itemHandle": item_handle,
|
|
"closed": true,
|
|
})
|
|
);
|
|
} else {
|
|
println!(
|
|
"session {} registered server {server_handle}, item {item_handle}, closed",
|
|
session.id()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn connect(connection: ConnectionArgs) -> Result<GatewayClient, Error> {
|
|
GatewayClient::connect(connection.options()).await
|
|
}
|
|
|
|
async fn connect_galaxy(connection: ConnectionArgs) -> Result<GalaxyClient, Error> {
|
|
GalaxyClient::connect(connection.options()).await
|
|
}
|
|
|
|
async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
|
|
match command {
|
|
GalaxyCommand::TestConnection { connection, json } => {
|
|
let mut client = connect_galaxy(connection).await?;
|
|
let ok = client.test_connection().await?;
|
|
if json {
|
|
println!("{}", json!({ "ok": ok }));
|
|
} else if ok {
|
|
println!("ok");
|
|
} else {
|
|
println!("not ok");
|
|
}
|
|
}
|
|
GalaxyCommand::LastDeployTime { connection, json } => {
|
|
let mut client = connect_galaxy(connection).await?;
|
|
let timestamp = client.get_last_deploy_time().await?;
|
|
match (json, timestamp) {
|
|
(true, Some(ts)) => {
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"present": true,
|
|
"seconds": ts.seconds,
|
|
"nanos": ts.nanos,
|
|
})
|
|
);
|
|
}
|
|
(true, None) => {
|
|
println!("{}", json!({ "present": false }));
|
|
}
|
|
(false, Some(ts)) => println!("{}.{:09}", ts.seconds, ts.nanos),
|
|
(false, None) => println!("(absent)"),
|
|
}
|
|
}
|
|
GalaxyCommand::Watch {
|
|
connection,
|
|
last_seen_deploy_time,
|
|
max_events,
|
|
json,
|
|
} => {
|
|
let mut client = connect_galaxy(connection).await?;
|
|
let last_seen = last_seen_deploy_time
|
|
.as_deref()
|
|
.map(parse_rfc3339_timestamp)
|
|
.transpose()?;
|
|
let mut stream = client.watch_deploy_events(last_seen).await?;
|
|
|
|
let mut count = 0usize;
|
|
loop {
|
|
tokio::select! {
|
|
biased;
|
|
signal = tokio::signal::ctrl_c() => {
|
|
signal.map_err(|err| Error::InvalidArgument {
|
|
name: "ctrl_c".to_owned(),
|
|
detail: err.to_string(),
|
|
})?;
|
|
// Drop the stream below by breaking; tonic tears the
|
|
// gRPC call down cooperatively.
|
|
break;
|
|
}
|
|
next = stream.next() => {
|
|
let Some(event) = next else { break; };
|
|
let event = event?;
|
|
count += 1;
|
|
print_deploy_event(&event, json);
|
|
if max_events != 0 && count >= max_events {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
GalaxyCommand::DiscoverHierarchy { connection, json } => {
|
|
let mut client = connect_galaxy(connection).await?;
|
|
let objects = client.discover_hierarchy().await?;
|
|
if json {
|
|
let payload: Vec<_> = objects
|
|
.iter()
|
|
.map(|object| {
|
|
json!({
|
|
"gobjectId": object.gobject_id,
|
|
"tagName": object.tag_name,
|
|
"containedName": object.contained_name,
|
|
"browseName": object.browse_name,
|
|
"parentGobjectId": object.parent_gobject_id,
|
|
"isArea": object.is_area,
|
|
"categoryId": object.category_id,
|
|
"hostedByGobjectId": object.hosted_by_gobject_id,
|
|
"templateChain": object.template_chain,
|
|
"attributes": object.attributes.iter().map(|attribute| json!({
|
|
"attributeName": attribute.attribute_name,
|
|
"fullTagReference": attribute.full_tag_reference,
|
|
"mxDataType": attribute.mx_data_type,
|
|
"dataTypeName": attribute.data_type_name,
|
|
"isArray": attribute.is_array,
|
|
"arrayDimension": attribute.array_dimension,
|
|
"arrayDimensionPresent": attribute.array_dimension_present,
|
|
"mxAttributeCategory": attribute.mx_attribute_category,
|
|
"securityClassification": attribute.security_classification,
|
|
"isHistorized": attribute.is_historized,
|
|
"isAlarm": attribute.is_alarm,
|
|
})).collect::<Vec<_>>(),
|
|
})
|
|
})
|
|
.collect();
|
|
println!("{}", json!({ "objects": payload }));
|
|
} else {
|
|
println!("{}", objects.len());
|
|
for object in &objects {
|
|
println!(
|
|
"{} {} {} ({} attribute(s))",
|
|
object.gobject_id,
|
|
object.tag_name,
|
|
object.browse_name,
|
|
object.attributes.len()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
GalaxyCommand::Browse {
|
|
connection,
|
|
parent_gobject_id,
|
|
category_ids,
|
|
template_chain_contains,
|
|
tag_name_glob,
|
|
include_attributes,
|
|
alarm_bearing_only,
|
|
historized_only,
|
|
depth,
|
|
json,
|
|
} => {
|
|
if parent_gobject_id.is_some() && depth > 0 {
|
|
eprintln!("warning: --depth is ignored when --parent-gobject-id is specified");
|
|
}
|
|
|
|
let mut client = connect_galaxy(connection).await?;
|
|
let options = BrowseChildrenOptions {
|
|
category_ids,
|
|
template_chain_contains,
|
|
tag_name_glob,
|
|
include_attributes: include_attributes.then_some(true),
|
|
alarm_bearing_only,
|
|
historized_only,
|
|
};
|
|
|
|
match parent_gobject_id {
|
|
// No parent → walk the lazy-browse tree from the root objects,
|
|
// eagerly expanding `depth` further levels so the print walks
|
|
// cached children without re-issuing RPCs.
|
|
None => {
|
|
let nodes = client.browse(Some(options)).await?;
|
|
for node in &nodes {
|
|
expand_to_depth(node, depth).await?;
|
|
}
|
|
if json {
|
|
let mut payload = Vec::with_capacity(nodes.len());
|
|
for node in &nodes {
|
|
payload.push(lazy_node_to_json(node).await);
|
|
}
|
|
println!("{}", json!({ "nodes": payload }));
|
|
} else {
|
|
println!("{}", nodes.len());
|
|
for node in &nodes {
|
|
print_lazy_node(node, 0).await;
|
|
}
|
|
}
|
|
}
|
|
// A specific parent → fetch exactly one level of children via
|
|
// the raw paged RPC. `--depth` is not meaningful here; the
|
|
// single-level children are returned as-is.
|
|
Some(parent) => {
|
|
let children = browse_children_one_level(&mut client, parent, &options).await?;
|
|
print_browse_children(&children, json);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Page size used for the raw `BrowseChildren` RPC when fetching a single
|
|
/// level via `--parent-gobject-id`. Mirrors `BROWSE_CHILDREN_PAGE_SIZE` in
|
|
/// `galaxy.rs` (the library's lazy-browse helper uses the same value).
|
|
const BROWSE_PAGE_SIZE: i32 = 500;
|
|
|
|
/// Drive `BrowseChildren` paging by hand for a single parent and return the
|
|
/// flattened child list. Used by the `browse --parent-gobject-id` path, which
|
|
/// surfaces one level of children rather than the lazy root-tree walk.
|
|
/// Record a non-empty `next_page_token` in `seen` and reject a repeat. A
|
|
/// server that returns the same continuation token twice would loop forever,
|
|
/// so the second sighting is converted to an `InvalidArgument` error. Extracted
|
|
/// from [`browse_children_one_level`] so the guard can be unit-tested without a
|
|
/// network client.
|
|
fn register_page_token(
|
|
seen: &mut std::collections::HashSet<String>,
|
|
token: &str,
|
|
) -> Result<(), Error> {
|
|
if !seen.insert(token.to_owned()) {
|
|
return Err(Error::InvalidArgument {
|
|
name: "page_token".to_owned(),
|
|
detail: format!("galaxy browse children returned repeated page token `{token}`"),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn browse_children_one_level(
|
|
client: &mut GalaxyClient,
|
|
parent_gobject_id: i32,
|
|
options: &BrowseChildrenOptions,
|
|
) -> Result<Vec<GalaxyBrowseChild>, Error> {
|
|
use std::collections::HashSet;
|
|
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::{
|
|
browse_children_request, BrowseChildrenRequest,
|
|
};
|
|
|
|
let mut children = Vec::new();
|
|
let mut page_token = String::new();
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
loop {
|
|
let request = BrowseChildrenRequest {
|
|
page_size: BROWSE_PAGE_SIZE,
|
|
page_token: page_token.clone(),
|
|
category_ids: options.category_ids.clone(),
|
|
template_chain_contains: options.template_chain_contains.clone(),
|
|
tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(),
|
|
include_attributes: options.include_attributes,
|
|
alarm_bearing_only: options.alarm_bearing_only,
|
|
historized_only: options.historized_only,
|
|
parent: Some(browse_children_request::Parent::ParentGobjectId(
|
|
parent_gobject_id,
|
|
)),
|
|
};
|
|
let reply = client.browse_children_raw(request).await?;
|
|
let hints = reply.child_has_children;
|
|
for (index, object) in reply.children.into_iter().enumerate() {
|
|
let has_children_hint = hints.get(index).copied().unwrap_or(false);
|
|
children.push(GalaxyBrowseChild {
|
|
object,
|
|
has_children_hint,
|
|
});
|
|
}
|
|
page_token = reply.next_page_token;
|
|
if page_token.is_empty() {
|
|
return Ok(children);
|
|
}
|
|
register_page_token(&mut seen, &page_token)?;
|
|
}
|
|
}
|
|
|
|
/// A single child returned by the raw `BrowseChildren` paging path, paired
|
|
/// with its server-supplied `child_has_children` hint.
|
|
struct GalaxyBrowseChild {
|
|
object: zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::GalaxyObject,
|
|
has_children_hint: bool,
|
|
}
|
|
|
|
/// Print the one-level children of a browsed parent, mirroring the JSON node
|
|
/// shape used by the root-tree walk (minus the recursive `children` array).
|
|
fn print_browse_children(children: &[GalaxyBrowseChild], use_json: bool) {
|
|
if use_json {
|
|
let payload: Vec<_> = children.iter().map(browse_child_to_json).collect();
|
|
println!("{}", json!({ "nodes": payload }));
|
|
} else {
|
|
println!("{}", children.len());
|
|
for child in children {
|
|
let object = &child.object;
|
|
let marker = if child.has_children_hint { "+" } else { "-" };
|
|
println!(
|
|
"{marker} {} {} (gobject {})",
|
|
object.tag_name, object.browse_name, object.gobject_id,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render one raw browse child as a JSON object whose key set matches the
|
|
/// lazy-node renderer (with an empty `children` array).
|
|
fn browse_child_to_json(child: &GalaxyBrowseChild) -> Value {
|
|
let object = &child.object;
|
|
json!({
|
|
"gobjectId": object.gobject_id,
|
|
"tagName": object.tag_name,
|
|
"containedName": object.contained_name,
|
|
"browseName": object.browse_name,
|
|
"parentGobjectId": object.parent_gobject_id,
|
|
"isArea": object.is_area,
|
|
"categoryId": object.category_id,
|
|
"hostedByGobjectId": object.hosted_by_gobject_id,
|
|
"templateChain": object.template_chain,
|
|
"hasChildrenHint": child.has_children_hint,
|
|
"attributes": object.attributes.iter().map(|attribute| json!({
|
|
"attributeName": attribute.attribute_name,
|
|
"fullTagReference": attribute.full_tag_reference,
|
|
"mxDataType": attribute.mx_data_type,
|
|
"dataTypeName": attribute.data_type_name,
|
|
"isArray": attribute.is_array,
|
|
"arrayDimension": attribute.array_dimension,
|
|
"arrayDimensionPresent": attribute.array_dimension_present,
|
|
"mxAttributeCategory": attribute.mx_attribute_category,
|
|
"securityClassification": attribute.security_classification,
|
|
"isHistorized": attribute.is_historized,
|
|
"isAlarm": attribute.is_alarm,
|
|
})).collect::<Vec<_>>(),
|
|
"children": Vec::<Value>::new(),
|
|
})
|
|
}
|
|
|
|
/// Recursively expand a [`LazyBrowseNode`] up to `depth` further levels. A
|
|
/// `depth` of 0 leaves the node unexpanded so the caller prints only the
|
|
/// requested level.
|
|
fn expand_to_depth(
|
|
node: &LazyBrowseNode,
|
|
depth: usize,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
|
|
Box::pin(async move {
|
|
if depth == 0 {
|
|
return Ok(());
|
|
}
|
|
node.expand().await?;
|
|
for child in node.children().await {
|
|
expand_to_depth(&child, depth - 1).await?;
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Print a [`LazyBrowseNode`] and any already-expanded descendants as an
|
|
/// indented tree. Indentation is two spaces per level.
|
|
fn print_lazy_node(
|
|
node: &LazyBrowseNode,
|
|
indent: usize,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
|
|
Box::pin(async move {
|
|
let object = node.object();
|
|
let marker = if node.has_children_hint() { "+" } else { "-" };
|
|
println!(
|
|
"{:indent$}{marker} {} {} (gobject {})",
|
|
"",
|
|
object.tag_name,
|
|
object.browse_name,
|
|
object.gobject_id,
|
|
indent = indent,
|
|
);
|
|
if node.is_expanded().await {
|
|
for child in node.children().await {
|
|
print_lazy_node(&child, indent + 2).await;
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Render a [`LazyBrowseNode`] (and its already-expanded children) as a JSON
|
|
/// object. Mirrors the `discover-hierarchy` object shape with an added
|
|
/// `hasChildrenHint` flag and a nested `children` array.
|
|
fn lazy_node_to_json(
|
|
node: &LazyBrowseNode,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Value> + Send + '_>> {
|
|
Box::pin(async move {
|
|
let object = node.object();
|
|
let mut children = Vec::new();
|
|
if node.is_expanded().await {
|
|
for child in node.children().await {
|
|
children.push(lazy_node_to_json(&child).await);
|
|
}
|
|
}
|
|
json!({
|
|
"gobjectId": object.gobject_id,
|
|
"tagName": object.tag_name,
|
|
"containedName": object.contained_name,
|
|
"browseName": object.browse_name,
|
|
"parentGobjectId": object.parent_gobject_id,
|
|
"isArea": object.is_area,
|
|
"categoryId": object.category_id,
|
|
"hostedByGobjectId": object.hosted_by_gobject_id,
|
|
"templateChain": object.template_chain,
|
|
"hasChildrenHint": node.has_children_hint(),
|
|
"attributes": object.attributes.iter().map(|attribute| json!({
|
|
"attributeName": attribute.attribute_name,
|
|
"fullTagReference": attribute.full_tag_reference,
|
|
"mxDataType": attribute.mx_data_type,
|
|
"dataTypeName": attribute.data_type_name,
|
|
"isArray": attribute.is_array,
|
|
"arrayDimension": attribute.array_dimension,
|
|
"arrayDimensionPresent": attribute.array_dimension_present,
|
|
"mxAttributeCategory": attribute.mx_attribute_category,
|
|
"securityClassification": attribute.security_classification,
|
|
"isHistorized": attribute.is_historized,
|
|
"isAlarm": attribute.is_alarm,
|
|
})).collect::<Vec<_>>(),
|
|
"children": children,
|
|
})
|
|
})
|
|
}
|
|
|
|
async fn session_for(
|
|
connection: ConnectionArgs,
|
|
session_id: String,
|
|
) -> Result<zb_mom_ww_mxgateway_client::Session, Error> {
|
|
let client = connect(connection).await?;
|
|
Ok(client.session(session_id))
|
|
}
|
|
|
|
/// End-of-result sentinel written to stdout after every batch command.
|
|
const BATCH_EOR: &str = "__MXGW_BATCH_EOR__";
|
|
|
|
/// Run the batch loop: read one command line at a time from stdin, dispatch
|
|
/// each through the normal [`dispatch`] path, and write [`BATCH_EOR`] to
|
|
/// stdout after every result. Errors are serialised as JSON to stdout so
|
|
/// the harness can parse them without interleaving stderr. The loop never
|
|
/// terminates on command error or accidental blank lines; only stdin EOF
|
|
/// ends the session — empty lines log an empty-EOR-bracketed result and
|
|
/// continue.
|
|
///
|
|
/// `std::io::Stdin::lock().lines()` is a blocking iterator and the dispatch
|
|
/// future is spawned on a separate tokio task so the runtime's main worker
|
|
/// stays free. When the runtime is multi-threaded the blocking read keeps
|
|
/// one worker parked on `ReadFile`; that is acceptable here because no other
|
|
/// future on the main task needs to run while we wait for the next command.
|
|
async fn run_batch() -> Result<(), Error> {
|
|
let stdin = io::stdin();
|
|
let stdout = io::stdout();
|
|
|
|
for line in stdin.lock().lines() {
|
|
let line = line.map_err(|e| Error::InvalidArgument {
|
|
name: "stdin".to_owned(),
|
|
detail: e.to_string(),
|
|
})?;
|
|
|
|
let parts: Vec<&str> = line.split_ascii_whitespace().collect();
|
|
if parts.is_empty() {
|
|
// Empty / whitespace-only line: log an empty-EOR-bracketed
|
|
// result and continue so accidental blank lines from the
|
|
// PowerShell e2e harness do not silently end the session.
|
|
println!("{BATCH_EOR}");
|
|
stdout.lock().flush().ok();
|
|
continue;
|
|
}
|
|
|
|
// Re-parse the split arguments under a fresh Cli, prepending the
|
|
// program-name placeholder so clap sees a complete argv[].
|
|
let parse_result =
|
|
Cli::try_parse_from(std::iter::once("mxgw-cli").chain(parts.iter().copied()));
|
|
|
|
let outcome: Result<(), Error> = match parse_result {
|
|
Ok(cli) => {
|
|
// Spawn on a new tokio task so each command runs with a fresh
|
|
// stack, avoiding stack overflow from the large dispatch future.
|
|
tokio::task::spawn(dispatch(cli.command))
|
|
.await
|
|
.unwrap_or_else(|join_err| {
|
|
Err(Error::InvalidArgument {
|
|
name: "task".to_owned(),
|
|
detail: join_err.to_string(),
|
|
})
|
|
})
|
|
}
|
|
Err(clap_err) => Err(Error::InvalidArgument {
|
|
name: "args".to_owned(),
|
|
detail: clap_err.to_string(),
|
|
}),
|
|
};
|
|
|
|
if let Err(err) = outcome {
|
|
// Write error as JSON to stdout so the harness sees it in the
|
|
// same stream as normal output, framed by the EOR sentinel.
|
|
println!(
|
|
"{}",
|
|
serde_json::json!({ "error": err.to_string(), "type": "error" })
|
|
);
|
|
}
|
|
|
|
println!("{BATCH_EOR}");
|
|
stdout.lock().flush().ok();
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn print_version(use_json: bool) {
|
|
if use_json {
|
|
println!("{}", version_json());
|
|
return;
|
|
}
|
|
|
|
println!("mxgw {CLIENT_VERSION}");
|
|
println!("gateway protocol {GATEWAY_PROTOCOL_VERSION}");
|
|
println!("worker protocol {WORKER_PROTOCOL_VERSION}");
|
|
}
|
|
|
|
fn version_json() -> Value {
|
|
json!({
|
|
"clientVersion": CLIENT_VERSION,
|
|
"gatewayProtocolVersion": GATEWAY_PROTOCOL_VERSION,
|
|
"workerProtocolVersion": WORKER_PROTOCOL_VERSION,
|
|
})
|
|
}
|
|
|
|
fn print_command_reply(
|
|
operation: &str,
|
|
reply: &zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::MxCommandReply,
|
|
use_json: bool,
|
|
) {
|
|
if use_json {
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"operation": operation,
|
|
"sessionId": reply.session_id,
|
|
"correlationId": reply.correlation_id,
|
|
"kind": reply.kind,
|
|
})
|
|
);
|
|
} else {
|
|
println!("{operation} completed");
|
|
}
|
|
}
|
|
|
|
fn print_handle(name: &str, handle: i32, use_json: bool) {
|
|
if use_json {
|
|
println!("{}", json!({ name: handle }));
|
|
} else {
|
|
println!("{handle}");
|
|
}
|
|
}
|
|
|
|
fn print_ok(operation: &str, use_json: bool) {
|
|
if use_json {
|
|
println!("{}", json!({ "operation": operation, "ok": true }));
|
|
} else {
|
|
println!("{operation} completed");
|
|
}
|
|
}
|
|
|
|
fn print_bulk_results(
|
|
operation: &str,
|
|
results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
|
|
use_json: bool,
|
|
) {
|
|
if use_json {
|
|
let results_json: Vec<_> = results
|
|
.iter()
|
|
.map(|result| {
|
|
json!({
|
|
"serverHandle": result.server_handle,
|
|
"tagAddress": result.tag_address,
|
|
"itemHandle": result.item_handle,
|
|
"wasSuccessful": result.was_successful,
|
|
"errorMessage": result.error_message,
|
|
})
|
|
})
|
|
.collect();
|
|
println!(
|
|
"{}",
|
|
json!({ "operation": operation, "results": results_json })
|
|
);
|
|
} else {
|
|
println!("{}", results.len());
|
|
}
|
|
}
|
|
|
|
fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error> {
|
|
let parsed = match value_type {
|
|
CliValueType::Bool => MxValue::bool(parse_cli_value(value)?),
|
|
CliValueType::Int32 => MxValue::int32(parse_cli_value(value)?),
|
|
CliValueType::Int64 => MxValue::int64(parse_cli_value(value)?),
|
|
CliValueType::Float => MxValue::float(parse_cli_value(value)?),
|
|
CliValueType::Double => MxValue::double(parse_cli_value(value)?),
|
|
CliValueType::String => MxValue::string(value),
|
|
};
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn print_write_bulk_results(
|
|
operation: &str,
|
|
results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::BulkWriteResult],
|
|
use_json: bool,
|
|
) {
|
|
if use_json {
|
|
let results_json: Vec<_> = results
|
|
.iter()
|
|
.map(|result| {
|
|
json!({
|
|
"serverHandle": result.server_handle,
|
|
"itemHandle": result.item_handle,
|
|
"wasSuccessful": result.was_successful,
|
|
"hresult": result.hresult,
|
|
"errorMessage": result.error_message,
|
|
})
|
|
})
|
|
.collect();
|
|
println!(
|
|
"{}",
|
|
json!({ "operation": operation, "results": results_json })
|
|
);
|
|
} else {
|
|
println!("{}", results.len());
|
|
}
|
|
}
|
|
|
|
fn print_read_bulk_results(
|
|
operation: &str,
|
|
results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::BulkReadResult],
|
|
use_json: bool,
|
|
) {
|
|
if use_json {
|
|
let results_json: Vec<_> = results
|
|
.iter()
|
|
.map(|result| {
|
|
json!({
|
|
"serverHandle": result.server_handle,
|
|
"tagAddress": result.tag_address,
|
|
"itemHandle": result.item_handle,
|
|
"wasSuccessful": result.was_successful,
|
|
"wasCached": result.was_cached,
|
|
"quality": result.quality,
|
|
"errorMessage": result.error_message,
|
|
})
|
|
})
|
|
.collect();
|
|
println!(
|
|
"{}",
|
|
json!({ "operation": operation, "results": results_json })
|
|
);
|
|
} else {
|
|
println!("{}", results.len());
|
|
}
|
|
}
|
|
|
|
/// Drive the cross-language ReadBulk stress benchmark from Rust: opens its
|
|
/// own session, subscribes to `bulk_size` tags so the worker's per-session
|
|
/// value cache populates from real OnDataChange events, hammers ReadBulk in
|
|
/// a tight loop for `duration_seconds` with per-call latency capture, and
|
|
/// emits the shared JSON stats schema the `scripts/bench-read-bulk.ps1`
|
|
/// driver collates across all five clients.
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn run_bench_read_bulk(
|
|
connection: ConnectionArgs,
|
|
client_name: String,
|
|
duration_seconds: u64,
|
|
warmup_seconds: u64,
|
|
bulk_size: usize,
|
|
tag_start: i32,
|
|
tag_prefix: String,
|
|
tag_attribute: String,
|
|
timeout_ms: u32,
|
|
json: bool,
|
|
) -> Result<(), Error> {
|
|
if bulk_size == 0 {
|
|
return Err(Error::InvalidArgument {
|
|
name: "bulk-size".to_owned(),
|
|
detail: "bulk-size must be positive".to_owned(),
|
|
});
|
|
}
|
|
if duration_seconds == 0 {
|
|
return Err(Error::InvalidArgument {
|
|
name: "duration-seconds".to_owned(),
|
|
detail: "duration-seconds must be positive".to_owned(),
|
|
});
|
|
}
|
|
|
|
// Build TestMachine_NNN.<attribute> tags with three-digit machine numbers
|
|
// matching the existing cross-language tag-discovery convention.
|
|
let tags: Vec<String> = (0..bulk_size)
|
|
.map(|index| {
|
|
format!(
|
|
"{prefix}{number:03}.{attr}",
|
|
prefix = tag_prefix,
|
|
number = tag_start + index as i32,
|
|
attr = tag_attribute,
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let endpoint = connection.endpoint.clone();
|
|
let client = connect(connection).await?;
|
|
let session = client
|
|
.open_session(OpenSessionRequest {
|
|
client_session_name: client_name.clone(),
|
|
..OpenSessionRequest::default()
|
|
})
|
|
.await?;
|
|
let session_id = session.id().to_owned();
|
|
|
|
// Subscribe so the worker's MxAccessValueCache populates from real
|
|
// OnDataChange events before the measurement window opens. Any per-tag
|
|
// failures fall through silently; the bench is still meaningful for the
|
|
// successfully-subscribed subset.
|
|
let bench_outcome = async {
|
|
let server_handle = session.register(&client_name).await?;
|
|
let subscribe_results = session.subscribe_bulk(server_handle, tags.clone()).await?;
|
|
let tags_ref: &[String] = &tags;
|
|
let item_handles: Vec<i32> = subscribe_results
|
|
.iter()
|
|
.filter(|r| r.was_successful)
|
|
.map(|r| r.item_handle)
|
|
.collect();
|
|
|
|
let timeout_ms_param = timeout_ms;
|
|
|
|
// Warm-up: drive identical calls so any connection-pool / channel
|
|
// setup is amortised before the measurement window opens.
|
|
let warmup_deadline = Instant::now() + Duration::from_secs(warmup_seconds);
|
|
while Instant::now() < warmup_deadline {
|
|
let _ = session
|
|
.read_bulk(server_handle, tags_ref, timeout_ms_param)
|
|
.await;
|
|
}
|
|
|
|
// Steady-state measurement window: capture per-call latency as
|
|
// sub-millisecond f64 deltas from Instant::now() so the histogram
|
|
// resolution matches the .NET Stopwatch / Go time.Now path.
|
|
let mut latencies_ms: Vec<f64> = Vec::with_capacity(65_536);
|
|
let mut total_read_results: i64 = 0;
|
|
let mut cached_read_results: i64 = 0;
|
|
let mut successful_calls: u64 = 0;
|
|
let mut failed_calls: u64 = 0;
|
|
let steady_start = Instant::now();
|
|
let steady_deadline = steady_start + Duration::from_secs(duration_seconds);
|
|
|
|
while Instant::now() < steady_deadline {
|
|
let call_start = Instant::now();
|
|
let result = session
|
|
.read_bulk(server_handle, tags_ref, timeout_ms_param)
|
|
.await;
|
|
let elapsed = call_start.elapsed();
|
|
latencies_ms.push(elapsed.as_secs_f64() * 1000.0);
|
|
match result {
|
|
Ok(results) => {
|
|
successful_calls += 1;
|
|
for r in &results {
|
|
total_read_results += 1;
|
|
if r.was_cached {
|
|
cached_read_results += 1;
|
|
}
|
|
}
|
|
}
|
|
Err(_) => failed_calls += 1,
|
|
}
|
|
}
|
|
let steady_elapsed = steady_start.elapsed();
|
|
|
|
// Best-effort cleanup: unsubscribe so the worker can release cache slots.
|
|
if !item_handles.is_empty() {
|
|
let _ = session.unsubscribe_bulk(server_handle, item_handles).await;
|
|
}
|
|
|
|
let total_calls = successful_calls + failed_calls;
|
|
let calls_per_second = if steady_elapsed.as_secs_f64() > 0.0 {
|
|
total_calls as f64 / steady_elapsed.as_secs_f64()
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
Ok::<_, Error>(BenchStats {
|
|
endpoint,
|
|
client_name,
|
|
bulk_size,
|
|
duration_seconds,
|
|
warmup_seconds,
|
|
duration_ms: steady_elapsed.as_millis() as u64,
|
|
tags: tags.clone(),
|
|
total_calls,
|
|
successful_calls,
|
|
failed_calls,
|
|
total_read_results,
|
|
cached_read_results,
|
|
calls_per_second,
|
|
latencies_ms,
|
|
})
|
|
}
|
|
.await;
|
|
|
|
// Always close the session, even if the bench loop returned an error.
|
|
let close_result = client
|
|
.close_session_raw(CloseSessionRequest {
|
|
session_id: session_id.clone(),
|
|
client_correlation_id: next_correlation_id("cli-bench-read-bulk-close"),
|
|
})
|
|
.await;
|
|
|
|
let stats = bench_outcome?;
|
|
// Closing the session is best-effort; never let it mask a real bench error.
|
|
let _ = close_result;
|
|
|
|
if json {
|
|
let latency = percentile_summary(&stats.latencies_ms);
|
|
let payload = json!({
|
|
"language": "rust",
|
|
"command": "bench-read-bulk",
|
|
"endpoint": stats.endpoint,
|
|
"clientName": stats.client_name,
|
|
"bulkSize": stats.bulk_size,
|
|
"durationSeconds": stats.duration_seconds,
|
|
"warmupSeconds": stats.warmup_seconds,
|
|
"durationMs": stats.duration_ms,
|
|
"tags": stats.tags,
|
|
"totalCalls": stats.total_calls,
|
|
"successfulCalls": stats.successful_calls,
|
|
"failedCalls": stats.failed_calls,
|
|
"totalReadResults": stats.total_read_results,
|
|
"cachedReadResults": stats.cached_read_results,
|
|
"callsPerSecond": round_to(stats.calls_per_second, 2),
|
|
"latencyMs": {
|
|
"p50": round_to(latency.p50, 3),
|
|
"p95": round_to(latency.p95, 3),
|
|
"p99": round_to(latency.p99, 3),
|
|
"max": round_to(latency.max, 3),
|
|
"mean": round_to(latency.mean, 3),
|
|
},
|
|
});
|
|
println!("{payload}");
|
|
} else {
|
|
println!("{}", stats.calls_per_second);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Collected bench-read-bulk measurements; carried in one struct so the
|
|
/// async block can finish cleanup (unsubscribe, close-session) before the
|
|
/// caller renders the JSON / plain output.
|
|
struct BenchStats {
|
|
endpoint: String,
|
|
client_name: String,
|
|
bulk_size: usize,
|
|
duration_seconds: u64,
|
|
warmup_seconds: u64,
|
|
duration_ms: u64,
|
|
tags: Vec<String>,
|
|
total_calls: u64,
|
|
successful_calls: u64,
|
|
failed_calls: u64,
|
|
total_read_results: i64,
|
|
cached_read_results: i64,
|
|
calls_per_second: f64,
|
|
latencies_ms: Vec<f64>,
|
|
}
|
|
|
|
/// The same `{ p50, p95, p99, max, mean }` shape every language bench emits.
|
|
/// `p50`/`p95`/`p99` use nearest-rank with linear interpolation, matching the
|
|
/// .NET / Go implementations so cross-language comparisons are apples-to-apples.
|
|
struct LatencySummary {
|
|
p50: f64,
|
|
p95: f64,
|
|
p99: f64,
|
|
max: f64,
|
|
mean: f64,
|
|
}
|
|
|
|
fn percentile_summary(sample: &[f64]) -> LatencySummary {
|
|
if sample.is_empty() {
|
|
return LatencySummary {
|
|
p50: 0.0,
|
|
p95: 0.0,
|
|
p99: 0.0,
|
|
max: 0.0,
|
|
mean: 0.0,
|
|
};
|
|
}
|
|
let mut sorted: Vec<f64> = sample.to_vec();
|
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
let max = sorted[sorted.len() - 1];
|
|
let mean: f64 = sample.iter().copied().sum::<f64>() / sample.len() as f64;
|
|
LatencySummary {
|
|
p50: percentile(&sorted, 0.50),
|
|
p95: percentile(&sorted, 0.95),
|
|
p99: percentile(&sorted, 0.99),
|
|
max,
|
|
mean,
|
|
}
|
|
}
|
|
|
|
fn percentile(sorted: &[f64], quantile: f64) -> f64 {
|
|
if sorted.is_empty() {
|
|
return 0.0;
|
|
}
|
|
if sorted.len() == 1 {
|
|
return sorted[0];
|
|
}
|
|
let rank = quantile * (sorted.len() - 1) as f64;
|
|
let lower = rank.floor() as usize;
|
|
let upper = lower + 1;
|
|
if upper >= sorted.len() {
|
|
return sorted[lower];
|
|
}
|
|
let fraction = rank - lower as f64;
|
|
sorted[lower] + (sorted[upper] - sorted[lower]) * fraction
|
|
}
|
|
|
|
fn round_to(value: f64, digits: u32) -> f64 {
|
|
let shift = 10f64.powi(digits as i32);
|
|
(value * shift).round() / shift
|
|
}
|
|
|
|
/// Pairs each parsed item handle with its parsed MxValue (proto form) so a
|
|
/// single helper can build the four bulk-write families without each branch
|
|
/// repeating the length check and per-value parsing.
|
|
fn build_write_bulk_entries(
|
|
item_handles: &[i32],
|
|
value_type: CliValueType,
|
|
values: &[String],
|
|
) -> Result<
|
|
Vec<(
|
|
i32,
|
|
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::MxValue,
|
|
)>,
|
|
Error,
|
|
> {
|
|
if item_handles.len() != values.len() {
|
|
return Err(Error::InvalidArgument {
|
|
name: "values".to_owned(),
|
|
detail: format!(
|
|
"item-handles count ({}) does not match values count ({})",
|
|
item_handles.len(),
|
|
values.len()
|
|
),
|
|
});
|
|
}
|
|
item_handles
|
|
.iter()
|
|
.zip(values.iter())
|
|
.map(|(handle, value)| {
|
|
parse_value(value_type, value).map(|wrapper| (*handle, wrapper.into_proto()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn print_deploy_event(event: &DeployEvent, use_json: bool) {
|
|
if use_json {
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"sequence": event.sequence,
|
|
"observedAt": event.observed_at.as_ref().map(|ts| json!({
|
|
"seconds": ts.seconds,
|
|
"nanos": ts.nanos,
|
|
})),
|
|
"timeOfLastDeploy": event.time_of_last_deploy.as_ref().map(|ts| json!({
|
|
"seconds": ts.seconds,
|
|
"nanos": ts.nanos,
|
|
})),
|
|
"timeOfLastDeployPresent": event.time_of_last_deploy_present,
|
|
"objectCount": event.object_count,
|
|
"attributeCount": event.attribute_count,
|
|
})
|
|
);
|
|
} else {
|
|
let observed = event
|
|
.observed_at
|
|
.as_ref()
|
|
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
|
|
.unwrap_or_else(|| "(absent)".to_owned());
|
|
let last_deploy = if event.time_of_last_deploy_present {
|
|
event
|
|
.time_of_last_deploy
|
|
.as_ref()
|
|
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
|
|
.unwrap_or_else(|| "(absent)".to_owned())
|
|
} else {
|
|
"(absent)".to_owned()
|
|
};
|
|
println!(
|
|
"seq={} observed={} lastDeploy={} objects={} attributes={}",
|
|
event.sequence, observed, last_deploy, event.object_count, event.attribute_count,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Render a streamed [`MxEvent`] as a JSON object. The scalar value is
|
|
/// projected into protojson-style `*Value` keys so the cross-language e2e
|
|
/// matrix can extract and compare event values uniformly across all five
|
|
/// client CLIs.
|
|
fn event_to_json(event: &MxEvent) -> Value {
|
|
// Match the other four CLIs by rendering the family as its protobuf enum
|
|
// name (e.g. MX_EVENT_FAMILY_ON_WRITE_COMPLETE). The e2e write round-trip
|
|
// looks up this name to confirm the OnWriteComplete echo arrived; emitting
|
|
// the raw i32 leaves it unable to recognise any event.
|
|
let family = MxEventFamily::try_from(event.family)
|
|
.map(|f| f.as_str_name())
|
|
.unwrap_or("MX_EVENT_FAMILY_UNSPECIFIED");
|
|
json!({
|
|
"family": family,
|
|
"sessionId": event.session_id,
|
|
"serverHandle": event.server_handle,
|
|
"itemHandle": event.item_handle,
|
|
"quality": event.quality,
|
|
"workerSequence": event.worker_sequence,
|
|
"value": event.value.as_ref().map(event_value_to_json),
|
|
})
|
|
}
|
|
|
|
/// Project an [`MxValue`] into a protojson-shaped JSON object whose single
|
|
/// key names the scalar kind (`int32Value`, `stringValue`, ...), matching
|
|
/// the protobuf-JSON the .NET/Go/Java CLIs emit.
|
|
fn event_value_to_json(value: &ProtoMxValue) -> Value {
|
|
match MxValue::from_proto(value.clone()).projection() {
|
|
MxValueProjection::Bool(inner) => json!({ "boolValue": inner }),
|
|
MxValueProjection::Int32(inner) => json!({ "int32Value": inner }),
|
|
// protojson renders 64-bit integers as strings; mirror that here.
|
|
MxValueProjection::Int64(inner) => json!({ "int64Value": inner.to_string() }),
|
|
MxValueProjection::Float(inner) => json!({ "floatValue": inner }),
|
|
MxValueProjection::Double(inner) => json!({ "doubleValue": inner }),
|
|
MxValueProjection::String(inner) => json!({ "stringValue": inner }),
|
|
MxValueProjection::Timestamp(ts) => {
|
|
json!({ "timestampValue": { "seconds": ts.seconds, "nanos": ts.nanos } })
|
|
}
|
|
MxValueProjection::Array(_) => json!({ "arrayValue": {} }),
|
|
MxValueProjection::Raw(bytes) => json!({ "rawValue": { "byteCount": bytes.len() } }),
|
|
MxValueProjection::Null => json!({ "isNull": true }),
|
|
MxValueProjection::Unset => Value::Null,
|
|
}
|
|
}
|
|
|
|
/// Render a streamed [`AlarmFeedMessage`] as a terse one-line summary that
|
|
/// distinguishes the four `payload` oneof cases.
|
|
fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String {
|
|
match &message.payload {
|
|
Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => {
|
|
format!(
|
|
"active-alarm {} state={}",
|
|
snapshot.alarm_full_reference,
|
|
AlarmEnumName::condition_state(snapshot.current_state)
|
|
)
|
|
}
|
|
Some(alarm_feed_message::Payload::SnapshotComplete(complete)) => {
|
|
format!("snapshot-complete {complete}")
|
|
}
|
|
Some(alarm_feed_message::Payload::Transition(transition)) => {
|
|
format!(
|
|
"transition {} kind={}",
|
|
transition.alarm_full_reference,
|
|
AlarmEnumName::transition_kind(transition.transition_kind)
|
|
)
|
|
}
|
|
Some(alarm_feed_message::Payload::ProviderStatus(status)) => {
|
|
format!(
|
|
"provider-status mode={} degraded={} reason={:?}",
|
|
AlarmEnumName::provider_mode(status.mode),
|
|
status.degraded,
|
|
status.reason
|
|
)
|
|
}
|
|
None => "(empty)".to_owned(),
|
|
}
|
|
}
|
|
|
|
/// Render a streamed [`AlarmFeedMessage`] as a JSON object whose single
|
|
/// top-level key names the active `payload` oneof case, mirroring the
|
|
/// protobuf-JSON the .NET/Go/Java/Python CLIs emit.
|
|
fn alarm_feed_message_to_json(message: &AlarmFeedMessage) -> Value {
|
|
match &message.payload {
|
|
Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => json!({
|
|
"activeAlarm": {
|
|
"alarmFullReference": snapshot.alarm_full_reference,
|
|
"sourceObjectReference": snapshot.source_object_reference,
|
|
"alarmTypeName": snapshot.alarm_type_name,
|
|
"severity": snapshot.severity,
|
|
"currentState": AlarmEnumName::condition_state(snapshot.current_state),
|
|
"category": snapshot.category,
|
|
"description": snapshot.description,
|
|
"operatorUser": snapshot.operator_user,
|
|
"operatorComment": snapshot.operator_comment,
|
|
}
|
|
}),
|
|
Some(alarm_feed_message::Payload::SnapshotComplete(complete)) => json!({
|
|
"snapshotComplete": complete,
|
|
}),
|
|
Some(alarm_feed_message::Payload::Transition(transition)) => json!({
|
|
"transition": {
|
|
"alarmFullReference": transition.alarm_full_reference,
|
|
"sourceObjectReference": transition.source_object_reference,
|
|
"alarmTypeName": transition.alarm_type_name,
|
|
"transitionKind": AlarmEnumName::transition_kind(transition.transition_kind),
|
|
"severity": transition.severity,
|
|
"operatorUser": transition.operator_user,
|
|
"operatorComment": transition.operator_comment,
|
|
"category": transition.category,
|
|
"description": transition.description,
|
|
}
|
|
}),
|
|
Some(alarm_feed_message::Payload::ProviderStatus(status)) => json!({
|
|
"providerStatus": {
|
|
"mode": AlarmEnumName::provider_mode(status.mode),
|
|
"degraded": status.degraded,
|
|
"reason": status.reason,
|
|
"since": status.since.as_ref().map(|ts| json!({
|
|
"seconds": ts.seconds,
|
|
"nanos": ts.nanos,
|
|
})),
|
|
}
|
|
}),
|
|
None => Value::Null,
|
|
}
|
|
}
|
|
|
|
/// Tiny namespace for alarm-enum name lookups used by the alarm-feed
|
|
/// renderers; keeps the proto-enum imports off the `main.rs` top level.
|
|
struct AlarmEnumName;
|
|
|
|
impl AlarmEnumName {
|
|
fn condition_state(value: i32) -> String {
|
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AlarmConditionState;
|
|
AlarmConditionState::try_from(value)
|
|
.map(|state| state.as_str_name().to_owned())
|
|
.unwrap_or_else(|_| value.to_string())
|
|
}
|
|
|
|
fn transition_kind(value: i32) -> String {
|
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AlarmTransitionKind;
|
|
AlarmTransitionKind::try_from(value)
|
|
.map(|kind| kind.as_str_name().to_owned())
|
|
.unwrap_or_else(|_| value.to_string())
|
|
}
|
|
|
|
fn provider_mode(value: i32) -> String {
|
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AlarmProviderMode;
|
|
AlarmProviderMode::try_from(value)
|
|
.map(|mode| mode.as_str_name().to_owned())
|
|
.unwrap_or_else(|_| value.to_string())
|
|
}
|
|
}
|
|
|
|
/// Render an [`AcknowledgeAlarmReply`] as a terse line or a JSON document.
|
|
fn print_acknowledge_alarm_reply(
|
|
reply: &zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AcknowledgeAlarmReply,
|
|
use_json: bool,
|
|
) {
|
|
if use_json {
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"operation": "acknowledge-alarm",
|
|
"correlationId": reply.correlation_id,
|
|
"protocolStatus": reply.protocol_status.as_ref().map(|status| json!({
|
|
"code": status.code,
|
|
"message": status.message,
|
|
})),
|
|
"hresult": reply.hresult,
|
|
"diagnosticMessage": reply.diagnostic_message,
|
|
})
|
|
);
|
|
} else {
|
|
println!("acknowledge-alarm completed");
|
|
}
|
|
}
|
|
|
|
/// Parse a small but practically-complete subset of RFC3339:
|
|
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
|
|
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
|
|
fn parse_rfc3339_timestamp(input: &str) -> Result<prost_types::Timestamp, Error> {
|
|
fn invalid(detail: impl Into<String>) -> Error {
|
|
Error::InvalidArgument {
|
|
name: "last-seen-deploy-time".to_owned(),
|
|
detail: detail.into(),
|
|
}
|
|
}
|
|
|
|
let bytes = input.as_bytes();
|
|
if bytes.len() < 20 || (bytes[10] != b'T' && bytes[10] != b't') {
|
|
return Err(invalid(format!(
|
|
"expected RFC3339 timestamp like 2026-04-28T15:30:00Z, got {input:?}"
|
|
)));
|
|
}
|
|
|
|
let read_u32 = |start: usize, len: usize| -> Result<u32, Error> {
|
|
std::str::from_utf8(&bytes[start..start + len])
|
|
.ok()
|
|
.and_then(|slice| slice.parse::<u32>().ok())
|
|
.ok_or_else(|| invalid(format!("non-numeric digits at byte {start}")))
|
|
};
|
|
|
|
let year = read_u32(0, 4)? as i32;
|
|
if bytes[4] != b'-' {
|
|
return Err(invalid("expected '-' after year"));
|
|
}
|
|
let month = read_u32(5, 2)?;
|
|
if bytes[7] != b'-' {
|
|
return Err(invalid("expected '-' after month"));
|
|
}
|
|
let day = read_u32(8, 2)?;
|
|
let hour = read_u32(11, 2)?;
|
|
if bytes[13] != b':' {
|
|
return Err(invalid("expected ':' after hour"));
|
|
}
|
|
let minute = read_u32(14, 2)?;
|
|
if bytes[16] != b':' {
|
|
return Err(invalid("expected ':' after minute"));
|
|
}
|
|
let second = read_u32(17, 2)?;
|
|
|
|
let mut cursor = 19usize;
|
|
let mut nanos: u32 = 0;
|
|
if cursor < bytes.len() && bytes[cursor] == b'.' {
|
|
cursor += 1;
|
|
let frac_start = cursor;
|
|
while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
|
|
cursor += 1;
|
|
}
|
|
let frac_len = cursor - frac_start;
|
|
if frac_len == 0 {
|
|
return Err(invalid("expected fractional digits after '.'"));
|
|
}
|
|
let take = frac_len.min(9);
|
|
let frac = std::str::from_utf8(&bytes[frac_start..frac_start + take])
|
|
.ok()
|
|
.and_then(|slice| slice.parse::<u32>().ok())
|
|
.ok_or_else(|| invalid("invalid fractional digits"))?;
|
|
nanos = frac * 10u32.pow(9u32.saturating_sub(take as u32));
|
|
}
|
|
|
|
let mut offset_seconds: i64 = 0;
|
|
if cursor >= bytes.len() {
|
|
return Err(invalid("missing timezone designator (Z or +HH:MM)"));
|
|
}
|
|
match bytes[cursor] {
|
|
b'Z' | b'z' => cursor += 1,
|
|
sign @ (b'+' | b'-') => {
|
|
cursor += 1;
|
|
if cursor + 5 > bytes.len() {
|
|
return Err(invalid("offset must be ±HH:MM"));
|
|
}
|
|
let oh = std::str::from_utf8(&bytes[cursor..cursor + 2])
|
|
.ok()
|
|
.and_then(|slice| slice.parse::<i64>().ok())
|
|
.ok_or_else(|| invalid("invalid offset hour"))?;
|
|
if bytes[cursor + 2] != b':' {
|
|
return Err(invalid("offset must contain ':' between HH and MM"));
|
|
}
|
|
let om = std::str::from_utf8(&bytes[cursor + 3..cursor + 5])
|
|
.ok()
|
|
.and_then(|slice| slice.parse::<i64>().ok())
|
|
.ok_or_else(|| invalid("invalid offset minute"))?;
|
|
cursor += 5;
|
|
let signed = if sign == b'-' { -1 } else { 1 };
|
|
offset_seconds = signed * (oh * 3600 + om * 60);
|
|
}
|
|
other => {
|
|
return Err(invalid(format!(
|
|
"unexpected timezone designator byte {other:?}"
|
|
)));
|
|
}
|
|
}
|
|
if cursor != bytes.len() {
|
|
return Err(invalid("trailing characters after timezone"));
|
|
}
|
|
|
|
let unix = ymdhms_to_unix(year, month, day, hour, minute, second)?;
|
|
let seconds = unix - offset_seconds;
|
|
|
|
Ok(prost_types::Timestamp {
|
|
seconds,
|
|
nanos: nanos as i32,
|
|
})
|
|
}
|
|
|
|
fn ymdhms_to_unix(
|
|
year: i32,
|
|
month: u32,
|
|
day: u32,
|
|
hour: u32,
|
|
minute: u32,
|
|
second: u32,
|
|
) -> Result<i64, Error> {
|
|
if !(1..=12).contains(&month) || day < 1 || hour > 23 || minute > 59 || second > 60 {
|
|
return Err(Error::InvalidArgument {
|
|
name: "last-seen-deploy-time".to_owned(),
|
|
detail: "calendar component out of range".to_owned(),
|
|
});
|
|
}
|
|
fn is_leap(year: i32) -> bool {
|
|
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
}
|
|
const DAYS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
let mut max = DAYS[(month - 1) as usize];
|
|
if month == 2 && is_leap(year) {
|
|
max = 29;
|
|
}
|
|
if day > max {
|
|
return Err(Error::InvalidArgument {
|
|
name: "last-seen-deploy-time".to_owned(),
|
|
detail: format!("day {day} out of range for month {month}/{year}"),
|
|
});
|
|
}
|
|
|
|
// Days from 1970-01-01 to year-month-day.
|
|
let mut total_days: i64 = 0;
|
|
if year >= 1970 {
|
|
for y in 1970..year {
|
|
total_days += if is_leap(y) { 366 } else { 365 };
|
|
}
|
|
} else {
|
|
for y in year..1970 {
|
|
total_days -= if is_leap(y) { 366 } else { 365 };
|
|
}
|
|
}
|
|
for m in 1..month {
|
|
let mut dim = DAYS[(m - 1) as usize];
|
|
if m == 2 && is_leap(year) {
|
|
dim = 29;
|
|
}
|
|
total_days += dim as i64;
|
|
}
|
|
total_days += (day - 1) as i64;
|
|
|
|
Ok(total_days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64)
|
|
}
|
|
|
|
fn parse_cli_value<T>(value: &str) -> Result<T, Error>
|
|
where
|
|
T: std::str::FromStr,
|
|
T::Err: std::fmt::Display,
|
|
{
|
|
value.parse::<T>().map_err(|source| Error::InvalidArgument {
|
|
name: "value".to_owned(),
|
|
detail: source.to_string(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use clap::Parser;
|
|
|
|
use super::{Cli, Command};
|
|
|
|
/// Pull the flattened `ConnectionArgs` out of a parsed `ping` command so
|
|
/// `ConnectionArgs::options()` can be exercised directly.
|
|
fn connection_from_ping(args: &[&str]) -> super::ConnectionArgs {
|
|
let mut full = vec!["mxgw", "ping"];
|
|
full.extend_from_slice(args);
|
|
match Cli::try_parse_from(full).expect("ping parse").command {
|
|
Command::Ping { connection, .. } => connection,
|
|
other => panic!("expected ping command, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_version_json_command() {
|
|
let parsed = Cli::try_parse_from(["mxgw", "version", "--json"]);
|
|
assert!(parsed.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn connection_defaults_to_plaintext() {
|
|
let options = connection_from_ping(&[]).options();
|
|
assert!(options.plaintext(), "default channel should be plaintext");
|
|
}
|
|
|
|
#[test]
|
|
fn connection_tls_flag_disables_plaintext() {
|
|
let options = connection_from_ping(&["--tls"]).options();
|
|
assert!(
|
|
!options.plaintext(),
|
|
"--tls must select an encrypted channel"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connection_plaintext_flag_selects_plaintext() {
|
|
let options = connection_from_ping(&["--plaintext"]).options();
|
|
assert!(options.plaintext());
|
|
}
|
|
|
|
#[test]
|
|
fn connection_rejects_tls_and_plaintext_together() {
|
|
let parsed = Cli::try_parse_from(["mxgw", "ping", "--tls", "--plaintext"]);
|
|
assert!(
|
|
parsed.is_err(),
|
|
"--tls and --plaintext must conflict so TLS cannot be silently downgraded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_write_command() {
|
|
let parsed = Cli::try_parse_from([
|
|
"mxgw",
|
|
"write",
|
|
"--session-id",
|
|
"session-1",
|
|
"--server-handle",
|
|
"12",
|
|
"--item-handle",
|
|
"34",
|
|
"--value-type",
|
|
"int32",
|
|
"--value",
|
|
"123",
|
|
]);
|
|
assert!(parsed.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn version_json_output_has_protocol_versions() {
|
|
let value = super::version_json();
|
|
|
|
assert_eq!(value["gatewayProtocolVersion"], 3);
|
|
assert_eq!(value["workerProtocolVersion"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_stream_alarms_command() {
|
|
let parsed = Cli::try_parse_from([
|
|
"mxgw",
|
|
"stream-alarms",
|
|
"--filter-prefix",
|
|
"Tank01",
|
|
"--max-events",
|
|
"3",
|
|
"--json",
|
|
]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_stream_alarms_command_without_filter_prefix() {
|
|
let parsed = Cli::try_parse_from(["mxgw", "stream-alarms"]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_acknowledge_alarm_command() {
|
|
let parsed = Cli::try_parse_from([
|
|
"mxgw",
|
|
"acknowledge-alarm",
|
|
"--reference",
|
|
"Tank01.Level.HiHi",
|
|
"--comment",
|
|
"ack from cli",
|
|
"--operator",
|
|
"operator1",
|
|
]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn acknowledge_alarm_requires_reference() {
|
|
let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
|
|
assert!(parsed.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
|
|
let parsed = Cli::try_parse_from([
|
|
"mxgw",
|
|
"galaxy",
|
|
"watch",
|
|
"--last-seen-deploy-time",
|
|
"2026-04-28T15:30:00Z",
|
|
"--max-events",
|
|
"5",
|
|
"--json",
|
|
]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_galaxy_watch_deploy_events_alias() {
|
|
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "watch-deploy-events"]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_galaxy_browse_command_with_filters_and_depth() {
|
|
let parsed = Cli::try_parse_from([
|
|
"mxgw",
|
|
"galaxy",
|
|
"browse",
|
|
"--parent-gobject-id",
|
|
"42",
|
|
"--category-id",
|
|
"3",
|
|
"--category-id",
|
|
"5",
|
|
"--template-contains",
|
|
"$DelmiaReceiver",
|
|
"--tag-name-glob",
|
|
"Recv_*",
|
|
"--include-attributes",
|
|
"--alarm-bearing-only",
|
|
"--depth",
|
|
"2",
|
|
"--json",
|
|
]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_galaxy_browse_command_with_defaults() {
|
|
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "browse"]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_batch_command() {
|
|
let parsed = Cli::try_parse_from(["mxgw", "batch"]);
|
|
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn batch_eor_marker_is_stable() {
|
|
assert_eq!(super::BATCH_EOR, "__MXGW_BATCH_EOR__");
|
|
}
|
|
|
|
#[test]
|
|
fn bench_percentile_summary_matches_hand_built_sample() {
|
|
// Hand-built sample with 5 values: 1, 2, 3, 4, 5.
|
|
let sample: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
|
let summary = super::percentile_summary(&sample);
|
|
|
|
assert_eq!(summary.max, 5.0);
|
|
// Mean = 15/5 = 3.0
|
|
assert!((summary.mean - 3.0).abs() < f64::EPSILON);
|
|
// p50: rank = 0.5 * 4 = 2 -> sorted[2] = 3.0
|
|
assert!((summary.p50 - 3.0).abs() < f64::EPSILON);
|
|
// p95: rank = 0.95 * 4 = 3.8 -> 4.0 + 0.8 * (5.0 - 4.0) = 4.8
|
|
assert!((summary.p95 - 4.8).abs() < f64::EPSILON);
|
|
// p99: rank = 0.99 * 4 = 3.96 -> 4.0 + 0.96 * 1.0 = 4.96
|
|
assert!((summary.p99 - 4.96).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn bench_percentile_summary_handles_empty_sample() {
|
|
let summary = super::percentile_summary(&[]);
|
|
assert_eq!(summary.p50, 0.0);
|
|
assert_eq!(summary.p95, 0.0);
|
|
assert_eq!(summary.p99, 0.0);
|
|
assert_eq!(summary.max, 0.0);
|
|
assert_eq!(summary.mean, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn bench_percentile_summary_handles_single_value_sample() {
|
|
let summary = super::percentile_summary(&[42.0]);
|
|
assert_eq!(summary.p50, 42.0);
|
|
assert_eq!(summary.p95, 42.0);
|
|
assert_eq!(summary.p99, 42.0);
|
|
assert_eq!(summary.max, 42.0);
|
|
assert_eq!(summary.mean, 42.0);
|
|
}
|
|
|
|
#[test]
|
|
fn register_page_token_accepts_distinct_tokens_and_rejects_repeats() {
|
|
let mut seen = std::collections::HashSet::new();
|
|
assert!(super::register_page_token(&mut seen, "tok-1").is_ok());
|
|
assert!(super::register_page_token(&mut seen, "tok-2").is_ok());
|
|
|
|
let repeated = super::register_page_token(&mut seen, "tok-1");
|
|
match repeated {
|
|
Err(super::Error::InvalidArgument { name, detail }) => {
|
|
assert_eq!(name, "page_token");
|
|
assert!(detail.contains("tok-1"), "detail: {detail}");
|
|
}
|
|
other => panic!("expected InvalidArgument on repeated token, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rfc3339_parser_rejects_trailing_characters() {
|
|
let err = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Zextra");
|
|
assert!(err.is_err(), "trailing chars after Z must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn rfc3339_parser_rejects_day_zero() {
|
|
let err = super::parse_rfc3339_timestamp("2026-04-00T15:30:00Z");
|
|
assert!(err.is_err(), "day 0 must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn rfc3339_parser_rejects_month_thirteen() {
|
|
let err = super::parse_rfc3339_timestamp("2026-13-01T15:30:00Z");
|
|
assert!(err.is_err(), "month 13 must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn rfc3339_parser_rejects_day_out_of_range_for_month() {
|
|
// April has 30 days.
|
|
let err = super::parse_rfc3339_timestamp("2026-04-31T15:30:00Z");
|
|
assert!(err.is_err(), "April 31 must be rejected");
|
|
// February 29 in a non-leap year.
|
|
let feb = super::parse_rfc3339_timestamp("2025-02-29T00:00:00Z");
|
|
assert!(feb.is_err(), "Feb 29 in a non-leap year must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
|
|
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
|
|
let utc = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Z").unwrap();
|
|
let plus = super::parse_rfc3339_timestamp("2026-04-28T16:30:00+01:00").unwrap();
|
|
let frac = super::parse_rfc3339_timestamp("2026-04-28T15:30:00.250Z").unwrap();
|
|
|
|
assert_eq!(utc.seconds, plus.seconds);
|
|
assert_eq!(utc.nanos, 0);
|
|
assert_eq!(frac.seconds, utc.seconds);
|
|
assert_eq!(frac.nanos, 250_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn alarm_feed_provider_status_renders_in_summary_and_json() {
|
|
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
|
|
alarm_feed_message, AlarmFeedMessage, AlarmProviderMode, AlarmProviderStatus,
|
|
};
|
|
|
|
let message = AlarmFeedMessage {
|
|
payload: Some(alarm_feed_message::Payload::ProviderStatus(
|
|
AlarmProviderStatus {
|
|
mode: AlarmProviderMode::Subtag as i32,
|
|
degraded: true,
|
|
reason: "alarmmgr unavailable".to_owned(),
|
|
since: Some(prost_types::Timestamp {
|
|
seconds: 1_777_995_000,
|
|
nanos: 0,
|
|
}),
|
|
},
|
|
)),
|
|
};
|
|
|
|
let summary = super::alarm_feed_message_summary(&message);
|
|
assert!(summary.contains("provider-status"), "summary: {summary}");
|
|
assert!(
|
|
summary.contains("ALARM_PROVIDER_MODE_SUBTAG"),
|
|
"summary: {summary}"
|
|
);
|
|
assert!(summary.contains("degraded=true"), "summary: {summary}");
|
|
|
|
let value = super::alarm_feed_message_to_json(&message);
|
|
let provider = &value["providerStatus"];
|
|
assert_eq!(provider["mode"], "ALARM_PROVIDER_MODE_SUBTAG");
|
|
assert_eq!(provider["degraded"], true);
|
|
assert_eq!(provider["reason"], "alarmmgr unavailable");
|
|
assert_eq!(provider["since"]["seconds"], 1_777_995_000_i64);
|
|
}
|
|
}
|