[F51] live ASB type-matrix: provision UDAs + capture wire fixtures + round-trip tests
rust / build / test / clippy / fmt (push) Has been cancelled
rust / cargo public-api drift check (F41) (push) Has been cancelled

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:
Joseph Doherty
2026-05-06 15:27:31 -04:00
parent 8bd66bbe65
commit c7505f9570
18 changed files with 461 additions and 0 deletions
+19
View File
@@ -28,6 +28,25 @@ If this changes (e.g. internal consumer wants registry-style versioning via a pr
**Resolves when:** the capture lands and R5's status is updated.
### F51 — Live type-matrix expansion for the ASB Variant codec (`asb-subscribe`)
**Status:** **Resolved 2026-05-06.** Provisioned 7 new UDAs (TestFloat / TestFloatArray / TestDouble / TestDoubleArray / TestDateTime / TestDuration / TestDurationArray) via `wwtools/graccesscli` `object uda add` against `$TestMachine`, deployed to `TestMachine_001`. New `crates/mxaccess/examples/asb-type-matrix.rs` reads each tag in a single batch and dumps the live `AsbVariant` bytes to per-tag fixture files when `MX_ASB_DUMP_FIXTURES=<dir>` is set.
Live evidence (one cold-start run; subsequent runs hit the F31 InvalidConnectionId cool-down — wait 60+ seconds with no ASB activity before re-running):
| 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 |
`crates/mxaccess-codec/tests/f51_type_matrix_parity.rs` round-trips each fixture: decode → re-encode → byte-equal + type_id / length pin. Per-fixture .bin files live under `crates/mxaccess-codec/tests/fixtures/f51-type-matrix/` once captured.
Array tags (`TestIntArray`, `TestBoolArray`, etc.) read live as `type_id=0 length=0 payload=0 bytes` because no consumer has written values to them — provisioned but unpopulated. Codec-side array round-trip is covered by `asb_variant`'s existing synthetic-payload unit tests; if/when value-write seeding lands, regenerate fixtures and add `*_array_round_trip` tests per shape. `docs/galaxy-test-fixtures.md` documents the full provisioning + regeneration recipe.
**Severity:** P2 — F32 was closed via "deployable maximum" interpretation (only Int32 verified live), but the codec supports Bool / Float / Double / String / DateTime / Duration / arrays without live evidence.
**Source:** F32 closeout (`design/followups.md`); `work_remain.md:108-113` documents the proven matrix from .NET captures — those types are codec-tested but not live-tested against MxDataProvider.
+83
View File
@@ -0,0 +1,83 @@
# Galaxy test fixtures
This document inventories the test tags provisioned on the local `ZB` Galaxy that the Rust port's live-test suite depends on. The tags are added to the `$TestMachine` template and propagate to every `TestMachine_NNN` instance after deploy.
## Provisioning
Done via [`wwtools/graccesscli`](../../wwtools/graccesscli) (`object uda add`). Each row below corresponds to one `graccess object uda add` invocation.
Repro (uses the bundled Debug build):
```powershell
$EXE = 'C:\Users\dohertj2\Desktop\wwtools\graccesscli\src\ZB.MOM.WW.GRAccess.Cli\bin\Debug\net48\ZB.MOM.WW.GRAccess.Cli.exe'
& $EXE object uda add --galaxy ZB --node . --name '$TestMachine' --type template `
--uda <name> --data-type <MxDataType> --category MxCategoryWriteable_USC_Lockable `
--security MxSecurityOperate `
[--is-array --array-count <N>] `
--confirm --confirm-target '$TestMachine' --llm-json
```
Then deploy:
```powershell
& $EXE instance deploy --galaxy ZB --node . --name TestMachine_001 --type instance `
--confirm --confirm-target TestMachine_001 --llm-json
```
## Inventory
**Pre-existing on `$TestMachine`** (verified via `docs/zb-testmachine.md`):
| UDA | Data type | Shape | Notes |
|---|---|---|---|
| `MachineCode` | `MxString` | scalar | F51 string-scalar fixture |
| `MachineDescription` | `MxString` | scalar | not currently used by tests |
| `MachineID` | `MxString` | scalar | not currently used by tests |
| `TestAlarm001` | `MxBoolean` | scalar | F51 bool-scalar fixture |
| `TestAlarm002` | `MxBoolean` | scalar | not currently used by tests |
| `TestAlarm003` | `MxBoolean` | scalar | not currently used by tests |
| `ProtectedValue` | `MxBoolean` | scalar | secured-write fixture |
| `ProtectedValue1` | `MxBoolean` | scalar | verified-write fixture |
| `TestHistoryValue` | `MxInteger` | scalar | not currently used by tests |
| `TestChangingInt` | `MxInteger` | scalar | F49 / F55 / F56 — driven by `UpdateTestChangingInt` script for buffered-subscribe live tests |
| `TestStringArray` | `MxString` | array | F51 string-array fixture (currently empty live) |
| `TestIntArray` | `MxInteger` | array | F51 int-array fixture (currently empty live) |
| `TestDateTimeArray` | `MxTime` | array | F51 datetime-array fixture (currently empty live) |
| `TestBoolArray` | `MxBoolean` | array | F51 bool-array fixture (currently empty live) |
**F51-provisioned (this commit, 2026-05-06)**:
| UDA | Data type | Shape | Live status |
|---|---|---|---|
| `TestFloat` | `MxFloat` | scalar | type_id=8 length=4 ✓ |
| `TestFloatArray` | `MxFloat` | array (4) | empty live (no value written) |
| `TestDouble` | `MxDouble` | scalar | type_id=9 length=8 ✓ |
| `TestDoubleArray` | `MxDouble` | array (4) | empty live (no value written) |
| `TestDateTime` | `MxTime` | scalar | type_id=11 length=8 ✓ |
| `TestDuration` | `MxElapsedTime` | scalar | type_id=12 length=8 ✓ |
| `TestDurationArray` | `MxElapsedTime` | array (4) | empty live (no value written) |
## Live wire-byte fixtures
`cargo run -p mxaccess --example asb-type-matrix --quiet` (with `MX_ASB_DUMP_FIXTURES=<dir>`) reads each tag and dumps the decoded `AsbVariant` payload as a per-tag `.bin` file:
```
crates/mxaccess-codec/tests/fixtures/f51-type-matrix/
├── TestMachine_001_TestChangingInt.bin (type_id=4 Int32 scalar)
├── TestMachine_001_TestAlarm001.bin (type_id=17 Boolean scalar)
├── TestMachine_001_MachineCode.bin (type_id=10 String scalar)
├── TestMachine_001_TestFloat.bin (type_id=8 Float scalar)
├── TestMachine_001_TestDouble.bin (type_id=9 Double scalar)
├── TestMachine_001_TestDateTime.bin (type_id=11 DateTime scalar)
└── TestMachine_001_TestDuration.bin (type_id=12 ElapsedTime scalar)
```
`crates/mxaccess-codec/tests/f51_type_matrix_parity.rs` round-trips each fixture: decode → re-encode → byte-equal assertion + type_id / length pin.
Array tags are excluded from the fixture set because the live engine returns `type_id=0 length=0` for them (default empty-array state — nothing has written to them yet). The codec's array round-trip is covered by `asb_variant`'s existing synthetic-payload unit tests; if/when array tags get value-write seeding, run the example again to regenerate fixtures and add a `*_array_round_trip` test per shape.
## Caveats
- The `TestFloatArray` / `TestDoubleArray` / `TestDurationArray` etc. arrays return empty payloads on `read` until something writes a value. Provisioning the array adds the metadata; populating the runtime value is a separate write-side step. F51 covers the codec-side round-trip via the existing synthetic unit tests.
- `MX_ASB_DUMP_FIXTURES` only fires when `MX_LIVE` is set (the example skips its body otherwise). The first register-after-AuthenticateMe sometimes returns `RESULT_CODE_INVALID_CONNECTION_ID = 1` per F31 — the example retries up to 6 times with backoff before giving up.
- Each tag's `length` field can shift between captures if the live value changes. The string fixture in particular ratchets with whatever `MachineCode` happens to hold at capture time.
@@ -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);
}
@@ -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")?,
)
}