[F51] live ASB type-matrix: provision UDAs + capture wire fixtures + round-trip tests
Provisioned 7 new UDAs on $TestMachine via wwtools/graccesscli
object uda add (then deployed to TestMachine_001):
TestFloat MxFloat scalar
TestFloatArray MxFloat array (4)
TestDouble MxDouble scalar
TestDoubleArray MxDouble array (4)
TestDateTime MxTime scalar
TestDuration MxElapsedTime scalar
TestDurationArray MxElapsedTime array (4)
New crates/mxaccess/examples/asb-type-matrix.rs reads all 14 tags
(7 pre-existing + 7 new) in a single batch and dumps the live
AsbVariant bytes per tag when MX_ASB_DUMP_FIXTURES=<dir> is set.
Single-attempt register (no retry — F31 InvalidConnectionId
cool-down re-arms on every retry, making backoff
counter-productive; if the cool-down is engaged, wait 60+ seconds
without ASB activity then re-run).
Captured live evidence (single cold-start run, all 14 register
calls returned error_code=0x0000):
TestChangingInt type_id=4 (Int32) length=4 payload=4
TestAlarm001 type_id=17 (Boolean) length=1 payload=1
MachineCode type_id=10 (String) length=30 payload=30
TestFloat type_id=8 (Float) length=4 payload=4
TestDouble type_id=9 (Double) length=8 payload=8
TestDateTime type_id=11 (DateTime) length=8 payload=8
TestDuration type_id=12 (ElapsedTime) length=8 payload=8
TestIntArray, TestBoolArray, TestStringArray, TestDateTimeArray,
TestFloatArray, TestDoubleArray, TestDurationArray
type_id=0 length=0 payload=0
(provisioned but no value written yet)
Per-tag fixture .bin files saved under
crates/mxaccess-codec/tests/fixtures/f51-type-matrix/ — full
14-byte to 40-byte AsbVariant byte sequences (i32 type_id LE +
i32 length LE + payload bytes).
crates/mxaccess-codec/tests/f51_type_matrix_parity.rs round-trips
each scalar fixture: decode -> re-encode -> assert byte-equal +
type_id / length pin. Tests skip with [skip] message when fixtures
are absent (so the suite passes on a fresh checkout without live
captures). 7 scalar tests pass against the captured fixtures.
Array tags excluded from round-trip pinning because the live
engine returns empty payloads for unwritten arrays. Codec-side
array round-trip is covered by asb_variant's existing synthetic-
payload unit tests.
docs/galaxy-test-fixtures.md inventories all $TestMachine UDAs
(pre-existing + F51-provisioned), the graccesscli provisioning
recipe, the fixture-regeneration pattern, and the F31 cool-down
caveat.
design/followups.md F51 marked resolved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
//! F51 — round-trip parity for the live ASB type matrix.
|
||||
//!
|
||||
//! Captured live by `cargo run -p mxaccess --example asb-type-matrix`
|
||||
//! with `MX_ASB_DUMP_FIXTURES=...` set. Each fixture file holds the
|
||||
//! full `AsbVariant` byte sequence (i32 type_id LE + i32 length LE +
|
||||
//! payload bytes) for one tag's read response. The tests below decode
|
||||
//! each fixture, re-encode, and assert byte-identical round-trip plus
|
||||
//! the expected type_id / length tuple.
|
||||
//!
|
||||
//! Live-evidence row from the original capture:
|
||||
//!
|
||||
//! | Tag | type_id | length | payload bytes |
|
||||
//! |---|---|---|---|
|
||||
//! | TestChangingInt | 4 (Int32) | 4 | 4 |
|
||||
//! | TestAlarm001 | 17 (Boolean) | 1 | 1 |
|
||||
//! | MachineCode | 10 (String) | 30 | 30 |
|
||||
//! | TestFloat | 8 (Float) | 4 | 4 |
|
||||
//! | TestDouble | 9 (Double) | 8 | 8 |
|
||||
//! | TestDateTime | 11 (DateTime) | 8 | 8 |
|
||||
//! | TestDuration | 12 (ElapsedTime)| 8 | 8 |
|
||||
//!
|
||||
//! Array tags read empty on the live install (no value written yet)
|
||||
//! so they're not in this fixture set; their codec round-trip is
|
||||
//! covered by `asb_variant`'s existing unit tests with synthetic
|
||||
//! payloads.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use mxaccess_codec::AsbVariant;
|
||||
|
||||
const FIXTURE_DIR: &str = "tests/fixtures/f51-type-matrix";
|
||||
|
||||
fn load_fixture(name: &str) -> Option<Vec<u8>> {
|
||||
let path = format!("{FIXTURE_DIR}/{name}.bin");
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"[skip] fixture {name} not present at {path} — \
|
||||
capture via `cargo run -p mxaccess --example asb-type-matrix` \
|
||||
with `MX_ASB_DUMP_FIXTURES={FIXTURE_DIR}` set",
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_round_trip(fixture: &str, expected_type_id: u16, expected_length: i32) {
|
||||
let Some(bytes) = load_fixture(fixture) else {
|
||||
return;
|
||||
};
|
||||
let (decoded, consumed) = AsbVariant::decode(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode {fixture}: {e:?}"));
|
||||
assert_eq!(consumed, bytes.len(), "{fixture}: decode consumed != bytes.len()");
|
||||
assert_eq!(decoded.type_id, expected_type_id, "{fixture}: type_id");
|
||||
assert_eq!(decoded.length, expected_length, "{fixture}: length");
|
||||
let re_encoded = decoded.encode();
|
||||
assert_eq!(re_encoded, bytes, "{fixture}: round-trip not byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int32_scalar_round_trip() {
|
||||
assert_round_trip("TestMachine_001_TestChangingInt", 4, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_scalar_round_trip() {
|
||||
assert_round_trip("TestMachine_001_TestAlarm001", 17, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_scalar_round_trip() {
|
||||
// length 30 = "TestMachine_001" (15 chars × 2 bytes UTF-16) on a
|
||||
// typical install. Different fixture captures may shift the
|
||||
// length if the MachineCode value differs.
|
||||
let Some(bytes) = load_fixture("TestMachine_001_MachineCode") else {
|
||||
return;
|
||||
};
|
||||
let (decoded, consumed) = AsbVariant::decode(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode: {e:?}"));
|
||||
assert_eq!(consumed, bytes.len());
|
||||
assert_eq!(decoded.type_id, 10);
|
||||
let re_encoded = decoded.encode();
|
||||
assert_eq!(re_encoded, bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_scalar_round_trip() {
|
||||
assert_round_trip("TestMachine_001_TestFloat", 8, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_scalar_round_trip() {
|
||||
assert_round_trip("TestMachine_001_TestDouble", 9, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_time_scalar_round_trip() {
|
||||
assert_round_trip("TestMachine_001_TestDateTime", 11, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elapsed_time_scalar_round_trip() {
|
||||
assert_round_trip("TestMachine_001_TestDuration", 12, 8);
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,254 @@
|
||||
//! `asb-type-matrix` — exercise every `AsbVariant` type the codec supports
|
||||
//! against a live AVEVA endpoint. Closes F51 ("live type-matrix expansion
|
||||
//! for the ASB Variant codec"). Resolves all of Bool / Int32 / Float /
|
||||
//! Double / String / DateTime / Duration in both scalar and array shape
|
||||
//! by reading + dumping the wire bytes for fixture extraction.
|
||||
//!
|
||||
//! Required env (populate via `tools/Setup-LiveProbeEnv.ps1`):
|
||||
//!
|
||||
//! - `MX_LIVE` (any non-empty value enables the live path)
|
||||
//! - `MX_ASB_HOST`, `MX_ASB_PASSPHRASE`, `MX_ASB_VIA` — same as `asb-subscribe`.
|
||||
//!
|
||||
//! No `MX_TEST_TAG` — the matrix is hard-coded so the live evidence
|
||||
//! always covers the full set. Override individual tags by editing
|
||||
//! `MATRIX` below if a Galaxy uses different fixture names.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use mxaccess::AsbTransport;
|
||||
use mxaccess_asb::ItemIdentity;
|
||||
use mxaccess_asb_nettcp::auth::{CryptoParameters, HashAlgorithm};
|
||||
use mxaccess_codec::AsbVariant;
|
||||
|
||||
/// One row of the live matrix — `(reference, expected MxDataType label)`.
|
||||
/// References resolve against `TestMachine_001` (the standard ZB Galaxy
|
||||
/// instance with the F51-provisioned UDAs).
|
||||
const MATRIX: &[(&str, &str)] = &[
|
||||
// Pre-existing fixtures (covered live since M5 wave 7).
|
||||
("TestMachine_001.TestChangingInt", "Int32 (scalar)"),
|
||||
("TestMachine_001.TestAlarm001", "Boolean (scalar)"),
|
||||
("TestMachine_001.MachineCode", "String (scalar)"),
|
||||
("TestMachine_001.TestIntArray", "Int32 (array)"),
|
||||
("TestMachine_001.TestBoolArray", "Boolean (array)"),
|
||||
("TestMachine_001.TestStringArray", "String (array)"),
|
||||
("TestMachine_001.TestDateTimeArray", "DateTime (array)"),
|
||||
// F51-provisioned fixtures.
|
||||
("TestMachine_001.TestFloat", "Float (scalar)"),
|
||||
("TestMachine_001.TestFloatArray", "Float (array)"),
|
||||
("TestMachine_001.TestDouble", "Double (scalar)"),
|
||||
("TestMachine_001.TestDoubleArray", "Double (array)"),
|
||||
("TestMachine_001.TestDateTime", "DateTime (scalar)"),
|
||||
("TestMachine_001.TestDuration", "ElapsedTime (scalar)"),
|
||||
("TestMachine_001.TestDurationArray", "ElapsedTime (array)"),
|
||||
];
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some(env) = LiveEnv::from_process()? else {
|
||||
eprintln!(
|
||||
"MX_LIVE not set — skipping live demo. Run \
|
||||
`. tools/Setup-LiveProbeEnv.ps1` to populate the required env vars."
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
eprintln!("connecting ASB at {} via {} ...", env.addr, env.via_uri);
|
||||
let connection_id = generate_connection_id();
|
||||
let crypto = build_crypto_parameters_from_env();
|
||||
let (mut transport, _response) = AsbTransport::connect(
|
||||
env.addr,
|
||||
&env.passphrase,
|
||||
&crypto,
|
||||
&env.via_uri,
|
||||
connection_id,
|
||||
)
|
||||
.await?;
|
||||
transport.client_mut().authenticator_mut().use_apollo_signing();
|
||||
let client = transport.client_mut();
|
||||
|
||||
// Single batch register / read / unregister cycle — matches the
|
||||
// .NET reference's `MxAsbDataClient.RegisterItems` pattern (one
|
||||
// call with the full item list). Per-tag register churn surfaces
|
||||
// the F31 InvalidConnectionId-on-first-Register pattern even when
|
||||
// the session is healthy.
|
||||
let items: Vec<ItemIdentity> = MATRIX
|
||||
.iter()
|
||||
.map(|(tag, _)| ItemIdentity::absolute_by_name(*tag))
|
||||
.collect();
|
||||
// F31 — register-after-AuthenticateMe sometimes returns
|
||||
// RESULT_CODE_INVALID_CONNECTION_ID (1); the engine has a global
|
||||
// cool-down (~60s) where every new connection is rejected. Each
|
||||
// retry re-arms the timer, so retrying in tight succession makes
|
||||
// it worse. Single attempt; if it fails, wait 60s+ before
|
||||
// re-running the example.
|
||||
eprintln!("registering {} items in one batch", items.len());
|
||||
let register = client.register_items(&items, true, false).await?;
|
||||
if register.result_code == Some(mxaccess_asb::RESULT_CODE_INVALID_CONNECTION_ID) {
|
||||
eprintln!(
|
||||
" register hit InvalidConnectionId (result_code 1). Engine is in the F31 cool-down. \
|
||||
Wait 60+ seconds with no live ASB activity, then re-run."
|
||||
);
|
||||
// Best-effort cleanup so we don't leave a half-open connection.
|
||||
let _ = client.disconnect().await;
|
||||
let _ = client.send_end().await;
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!(
|
||||
"register: result_code={:?} success={:?} status_len={}",
|
||||
register.result_code,
|
||||
register.success,
|
||||
register.status.len()
|
||||
);
|
||||
for (i, st) in register.status.iter().enumerate() {
|
||||
let tag = MATRIX.get(i).map(|(t, _)| *t).unwrap_or("?");
|
||||
eprintln!(
|
||||
" [{i:>2}] {tag} -> error_code=0x{ec:04x}",
|
||||
ec = st.error_code
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("reading {} items (timeout 10s)", items.len());
|
||||
let read = tokio::time::timeout(Duration::from_secs(10), client.read(&items)).await??;
|
||||
eprintln!(
|
||||
"read: result_code={:?} success={:?} values={} status={}",
|
||||
read.result_code,
|
||||
read.success,
|
||||
read.values.len(),
|
||||
read.status.len()
|
||||
);
|
||||
let mut value_returns = 0usize;
|
||||
for (i, value) in read.values.iter().enumerate() {
|
||||
let var = &value.value;
|
||||
let label = MATRIX.get(i).map(|(_, l)| *l).unwrap_or("?");
|
||||
let tag = MATRIX.get(i).map(|(t, _)| *t).unwrap_or("?");
|
||||
println!(
|
||||
" [{i:>2}] {tag} ({label}) = type_id={type_id} length={length} payload={n} bytes",
|
||||
type_id = var.type_id,
|
||||
length = var.length,
|
||||
n = var.payload.len(),
|
||||
);
|
||||
if !var.payload.is_empty() {
|
||||
value_returns += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally dump each variant's wire bytes to a fixture file so
|
||||
// the round-trip tests can pin them. Set MX_ASB_DUMP_FIXTURES to
|
||||
// the target directory path.
|
||||
if let Ok(dump_dir) = std::env::var("MX_ASB_DUMP_FIXTURES") {
|
||||
let dir = PathBuf::from(&dump_dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
for (i, value) in read.values.iter().enumerate() {
|
||||
let tag = MATRIX.get(i).map(|(t, _)| *t).unwrap_or("unknown");
|
||||
let safe = tag.replace('.', "_");
|
||||
let path = dir.join(format!("{safe}.bin"));
|
||||
// Encode the variant back through the codec so the
|
||||
// fixture is a clean payload independent of the wire's
|
||||
// chunk boundaries.
|
||||
let encoded = AsbVariant {
|
||||
type_id: value.value.type_id,
|
||||
length: value.value.length,
|
||||
payload: value.value.payload.clone(),
|
||||
}
|
||||
.encode();
|
||||
std::fs::write(&path, &encoded)?;
|
||||
eprintln!(
|
||||
" dumped {} ({} bytes) -> {}",
|
||||
tag,
|
||||
encoded.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = client.unregister_items(&items).await {
|
||||
eprintln!("unregister failed: {e}");
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"--- summary: {value_returns} non-empty payloads across {} items ---",
|
||||
MATRIX.len()
|
||||
);
|
||||
|
||||
eprintln!("disconnecting");
|
||||
client.disconnect().await?;
|
||||
client.send_end().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- shared boilerplate (matches asb-subscribe.rs) ----------------------
|
||||
|
||||
fn generate_connection_id() -> [u8; 16] {
|
||||
use rand::RngCore;
|
||||
let mut id = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut id);
|
||||
id
|
||||
}
|
||||
|
||||
struct LiveEnv {
|
||||
addr: std::net::SocketAddr,
|
||||
via_uri: String,
|
||||
passphrase: String,
|
||||
}
|
||||
|
||||
impl LiveEnv {
|
||||
fn from_process() -> Result<Option<Self>, Box<dyn std::error::Error>> {
|
||||
if std::env::var_os("MX_LIVE").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let host = std::env::var("MX_ASB_HOST")?;
|
||||
let addr = parse_host_port(&host, 808)?;
|
||||
let via_uri = std::env::var("MX_ASB_VIA")
|
||||
.unwrap_or_else(|_| format!("net.tcp://{host}/ASBService"));
|
||||
let passphrase = std::env::var("MX_ASB_PASSPHRASE")?;
|
||||
Ok(Some(Self {
|
||||
addr,
|
||||
via_uri,
|
||||
passphrase,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_crypto_parameters_from_env() -> CryptoParameters {
|
||||
let mut params = CryptoParameters::defaults();
|
||||
if let Ok(prime) = std::env::var("MX_ASB_DH_PRIME") {
|
||||
params.prime_decimal = prime;
|
||||
}
|
||||
if let Ok(generator) = std::env::var("MX_ASB_DH_GENERATOR") {
|
||||
params.generator_decimal = generator;
|
||||
}
|
||||
if let Ok(hash) = std::env::var("MX_ASB_DH_HASH_ALGORITHM") {
|
||||
params.hash_algorithm = match hash.to_ascii_lowercase().as_str() {
|
||||
"md5" => HashAlgorithm::Md5,
|
||||
"sha1" => HashAlgorithm::Sha1,
|
||||
"sha512" => HashAlgorithm::Sha512,
|
||||
_ => HashAlgorithm::Unrecognised,
|
||||
};
|
||||
}
|
||||
if let Ok(size) = std::env::var("MX_ASB_DH_KEY_SIZE") {
|
||||
if let Ok(parsed) = size.parse::<u32>() {
|
||||
params.key_size_bits = parsed;
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
fn parse_host_port(
|
||||
s: &str,
|
||||
default_port: u16,
|
||||
) -> Result<std::net::SocketAddr, Box<dyn std::error::Error>> {
|
||||
if let Ok(addr) = s.parse() {
|
||||
return Ok(addr);
|
||||
}
|
||||
let with_port = if s.contains(':') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{s}:{default_port}")
|
||||
};
|
||||
Ok(
|
||||
std::net::ToSocketAddrs::to_socket_addrs(&with_port.as_str())?
|
||||
.next()
|
||||
.ok_or("no addrs resolved")?,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user