[M5] mxaccess: asb-subscribe example exercises full F25+F26 stack

Replaces the M5 placeholder with an actual end-to-end demo:

  AsbTransport::connect (TCP + preamble + DH handshake)
  → register_items
  → read
  → disconnect
  → send_end

Until F25 subscription ops (CreateSubscription / AddMonitoredItems
/ Publish-callback) land, the example is a Read-loop demo. Once
subscription ops arrive, it gains a Publish-loop and lives up to
its name.

Env vars (analogous to the NMX `connect-write-read` example):
  MX_LIVE — non-empty enables the live path
  MX_ASB_HOST — endpoint host[:port]; defaults port 5074
  MX_ASB_PASSPHRASE — solution shared secret
  MX_ASB_VIA — `net.tcp://...` URI (optional; derived from MX_ASB_HOST
    when omitted)
  MX_TEST_TAG — tag reference (default `TestChildObject.TestInt`)

Without MX_LIVE: prints the `Setup-LiveProbeEnv.ps1` hint and exits
cleanly with status 0 — the same pattern every other live example
follows.

Connection-id is a fresh 16-byte random buffer (matches .NET's
`Guid.NewGuid()` at `MxAsbDataClient.cs:36`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 12:34:24 -04:00
parent 14bb5297a8
commit c6570dcd06
2 changed files with 149 additions and 32 deletions
+144 -31
View File
@@ -1,44 +1,157 @@
//! `asb-subscribe` — subscribe via the ASB transport (M5 placeholder).
//! `asb-subscribe` — bring up an ASB session and exercise RegisterItems +
//! Read against a live AVEVA endpoint.
//!
//! ASB (`net.tcp` to MxDataProvider) is the M5 milestone — the
//! `mxaccess-asb-nettcp` framing crate and `mxaccess-asb` operations crate
//! are scaffolded but not yet wired into `Session`. Once M5 lands the demo
//! body below becomes a ~30-line subscribe + drain identical in shape to
//! `subscribe.rs`, just over the ASB transport.
//! Despite the example's historical name, true `Subscribe` over ASB
//! requires the F25 subscription operations (CreateSubscription /
//! AddMonitoredItems / Publish-callback) which are not yet implemented.
//! This example exercises the proven F25/F26 path:
//!
//! See `design/60-roadmap.md` M5 for the operations matrix and
//! `docs/ASB-Native-Integration-Decision.md` for why ASB is the preferred
//! data-plane.
//! `AsbTransport::connect` (TCP + preamble + DH handshake)
//! → `AsbClient::register_items`
//! → `AsbClient::read`
//! → `AsbClient::disconnect`
//! → `AsbClient::send_end`
//!
//! Once F25 subscription ops land, this example will gain a short
//! Publish-loop. Until then it's a Read-loop demo.
//!
//! # Required env vars
//!
//! Populate via `tools/Setup-LiveProbeEnv.ps1` (dot-source it):
//!
//! - `MX_LIVE` (any non-empty value enables the live path)
//! - `MX_ASB_HOST` — ASB endpoint host[:port]; defaults port 5074 if omitted
//! - `MX_ASB_PASSPHRASE` — solution shared secret (typically read from
//! DPAPI on a real install; for CI / dev set directly via Infisical
//! per `tools/Setup-LiveProbeEnv.ps1`)
//! - `MX_ASB_VIA` — `net.tcp://host:port/ASBService` URL (optional;
//! derived from `MX_ASB_HOST` when omitted)
//! - `MX_TEST_TAG` — tag reference (default `TestChildObject.TestInt`)
use mxaccess::{ConnectionOptions, Session};
use std::time::Duration;
use mxaccess::AsbTransport;
use mxaccess_asb::ItemIdentity;
use mxaccess_asb_nettcp::auth::CryptoParameters;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var_os("MX_LIVE").is_none() {
let Some(env) = LiveEnv::from_process()? else {
eprintln!(
"MX_LIVE not set — `asb-subscribe` is the M5 placeholder; \
run `. tools/Setup-LiveProbeEnv.ps1` once the ASB transport lands."
"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 (mut transport, response) = AsbTransport::connect(
env.addr,
&env.passphrase,
&CryptoParameters::defaults(),
&env.via_uri,
connection_id,
)
.await?;
eprintln!(
"connected; lifetime={:?} apollo={}",
response.connection_lifetime,
transport
.client_mut()
.authenticator_mut()
.use_apollo_signing()
);
let client = transport.client_mut();
let items = vec![ItemIdentity::absolute_by_name(&env.tag)];
eprintln!("registering {}", env.tag);
let register = client.register_items(&items, true, false).await?;
eprintln!(
"register status: {} item(s); first error_code = 0x{:04x}",
register.status.len(),
register.status.first().map(|s| s.error_code).unwrap_or(0)
);
eprintln!("reading {} (timeout 5s)", env.tag);
let read = tokio::time::timeout(Duration::from_secs(5), client.read(&items)).await??;
for (status, value) in read.status.iter().zip(read.values.iter()) {
println!(
"{} = {:?} (error_code 0x{:04x})",
status.item.name.as_deref().unwrap_or("?"),
value.value,
status.error_code
);
}
if read.values.is_empty() {
println!("{} returned no values yet (status only)", env.tag);
}
match Session::connect(ConnectionOptions).await {
Ok(_) => {
eprintln!(
"Session::connect returned Ok unexpectedly — \
update this example once M5 wires the ASB transport."
);
}
Err(mxaccess::Error::Unsupported {
operation,
transport,
}) => {
eprintln!(
"{operation} on {transport:?}: deferred to M5. See \
design/60-roadmap.md M5 for the ASB transport operations matrix."
);
}
Err(e) => return Err(e.into()),
}
eprintln!("disconnecting");
client.disconnect().await?;
client.send_end().await?;
Ok(())
}
// ---- live-env wiring --------------------------------------------------------
struct LiveEnv {
addr: std::net::SocketAddr,
passphrase: String,
via_uri: String,
tag: 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, 5074)?;
let passphrase = std::env::var("MX_ASB_PASSPHRASE")
.map_err(|_| "MX_ASB_PASSPHRASE not set — ASB requires the solution shared secret")?;
let via_uri =
std::env::var("MX_ASB_VIA").unwrap_or_else(|_| format!("net.tcp://{host}/ASBService"));
let tag = std::env::var("MX_TEST_TAG").unwrap_or_else(|_| "TestChildObject.TestInt".into());
Ok(Some(Self {
addr,
passphrase,
via_uri,
tag,
}))
}
}
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")?,
)
}
/// Generate a fresh 16-byte connection-id GUID for this session. We
/// could pull `uuid::Uuid::new_v4()` for a real RFC 4122 v4, but the
/// example deliberately stays dep-light — `rand::random::<[u8; 16]>()`
/// is sufficient since the field is opaque to the service (the .NET
/// reference at `MxAsbDataClient.cs:36` uses `Guid.NewGuid()` which
/// is also a v4 random GUID).
fn generate_connection_id() -> [u8; 16] {
use rand::RngCore;
let mut bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
bytes
}