[M5] mxaccess: F26 step 1 — AsbTransport bridges AsbClient into Transport trait

First slice of F26. Bridges F25's working AsbClient into the M0
`mxaccess::Transport` trait that Session uses to discriminate
operations across NMX and ASB transports.

API additions:
* `mxaccess::AsbTransport<T>` — generic over the same
  AsyncRead+AsyncWrite+Unpin+Send+Sync+'static bound that AsbClient
  takes. Owns an AsbClient and exposes it via `client_mut()` /
  `into_client()`.
* `impl Transport for AsbTransport<T>`:
  - `capabilities()` — `buffered_subscribe = false`,
    `activate_suspend = false`, `operation_complete_frame = false`
    per `design/60-roadmap.md` M5 (no NMX-specific extensions on
    ASB).
  - `kind()` — `TransportKind::Asb`.

Path-dep wiring: `mxaccess` now imports `mxaccess-asb` +
`mxaccess-asb-nettcp` directly.

Compile-time `Send + Sync + 'static` assertion guards the
trait-bound contract.

2 new tests:
* `asb_transport_kind_is_asb`.
* `asb_transport_capabilities_disable_buffered_and_activate_suspend`.

Stubbed for F26 step 2:
* `Session::connect_asb` constructor that owns TCP open +
  preamble + DH handshake orchestration.
* Operation routing that maps ASB types (ItemStatus, RuntimeValue)
  back to mxaccess types (MxStatus, DataChange, MxValue).

Stubbed for F26 step 3:
* Subscription routing — Session::subscribe on ASB needs F25
  subscription operations (CreateSubscription / AddMonitoredItems
  / Publish), which are not yet implemented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 11:57:20 -04:00
parent 1b1ee1e0b7
commit 8a0f92b6bc
5 changed files with 141 additions and 1 deletions
+5 -1
View File
@@ -46,7 +46,11 @@ move to `## Resolved` with a date + commit hash.
**Resolves when:** F19-F26 are all closed and the four DoD bullets above pass.
**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 landed in this commit:
**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 (`1b1ee1e`); F26 step 1 landed in this commit:
- F26 step 1: `mxaccess::AsbTransport` — bridges F25's `AsbClient` into the M0 `Transport` trait. Generic over `T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static` (the same bounds AsbClient takes). `Transport::capabilities()` returns the ASB-specific flags per `design/60-roadmap.md` M5: `buffered_subscribe = false`, `activate_suspend = false`, `operation_complete_frame = false`. `Transport::kind()` returns `TransportKind::Asb`. `AsbTransport::new(client)` / `into_client()` / `client_mut()` for transport↔client conversion. New deps: `mxaccess` now path-deps `mxaccess-asb` + `mxaccess-asb-nettcp`. Compile-time `Send + Sync + 'static` assertion guards the trait-bound contract. 2 new tests: kind == Asb; capabilities all false. **Stubbed for F26 step 2:** `Session::connect_asb` constructor that owns the full TCP-open + preamble + DH handshake orchestration, plus operation routing that maps ASB types (`ItemStatus`, `RuntimeValue`) back to `mxaccess` types (`MxStatus`, `DataChange`, `MxValue`). Stubbed for F26 step 3: subscription routing — `Session::subscribe` on ASB maps to a `CreateSubscription` + `AddMonitoredItems` + `Publish`-callback pipeline; F25 subscription operations themselves are not yet implemented.
**Earlier slices:**
- F25 step 7 (commit `1b1ee1e`):
- F25 step 7: Disconnect operation (closes the connection lifecycle: Connect → ops → Disconnect → End → close). New `build_disconnect_request_body(data, iv)` mirrors `AsbContracts.cs:109-114` (`<DisconnectRequest><ConsumerAuthenticationData><Data/><InitializationVector/></ConsumerAuthenticationData></DisconnectRequest>`) — same payload shape as AuthenticateMe but under a different wrapper element. New `client::disconnect()` builds a fresh encrypted authentication-data blob via F23's `create_authentication_data` (encrypts `local_pub || remote_pub` under the derived AES key with a fresh IV), wraps it, and sends one-way + signed (regular HMAC, no force). 2 new tests: `disconnect_request_carries_data_and_iv_under_correct_wrapper` (checks wrapper element name + Data/IV byte ordering), and end-to-end `disconnect_writes_signed_one_way_envelope` via `tokio::io::duplex` peer that verifies the encoded SizedEnvelope contains the disconnectIn action string. With Disconnect landed, `AsbClient` now covers the full session lifecycle: `send_preamble().await? → connect().await? → register_items()/read()/keep_alive()/unregister_items() → disconnect().await? → send_end().await?`.
**Earlier slices:**
+2
View File
@@ -339,6 +339,8 @@ version = "0.0.0"
dependencies = [
"async-trait",
"futures-util",
"mxaccess-asb",
"mxaccess-asb-nettcp",
"mxaccess-callback",
"mxaccess-codec",
"mxaccess-galaxy",
+2
View File
@@ -14,6 +14,8 @@ mxaccess-callback = { path = "../mxaccess-callback" }
mxaccess-galaxy = { path = "../mxaccess-galaxy" }
mxaccess-nmx = { path = "../mxaccess-nmx" }
mxaccess-rpc = { path = "../mxaccess-rpc" }
mxaccess-asb = { path = "../mxaccess-asb" }
mxaccess-asb-nettcp = { path = "../mxaccess-asb-nettcp" }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
+3
View File
@@ -30,6 +30,9 @@ pub use mxaccess_codec::{
// ---- Public types --------------------------------------------------------
pub mod session;
pub mod transport_asb;
pub use transport_asb::AsbTransport;
pub use mxaccess_galaxy::{GalaxyTagMetadata, Resolver, ResolverError};
pub use mxaccess_nmx::WriteValue;
+129
View File
@@ -0,0 +1,129 @@
//! `AsbTransport` — bridges the F25 `mxaccess_asb::AsbClient` into the
//! `mxaccess::Transport` trait + `Session` API.
//!
//! Per `design/60-roadmap.md` M5, the ASB transport surfaces:
//!
//! * **No `subscribe_buffered`** — ASB has no proven equivalent of NMX's
//! buffered-batch DataUpdate frame; consumers calling
//! `Session::subscribe_buffered` over ASB get
//! `Error::Unsupported(Capability::BufferedSubscribe)`.
//! * **No `Activate` / `Suspend`** — these are NMX `INmxService2`
//! primitives without an ASB analogue.
//! * **No `OperationComplete` outside the proven write-completion frame**
//! — ASB doesn't surface a generic completion-frame channel.
//!
//! ## Scope of this iteration (F26 step 1)
//!
//! Implements:
//! * [`AsbTransport`] struct that owns an [`AsbClient`] over an
//! `AsyncRead + AsyncWrite + Unpin + Send` transport.
//! * [`Transport`] trait impl returning the capability flags above.
//! * [`AsbTransport::new`] constructor.
//!
//! Stubbed for next F26 iteration:
//! * `Session::connect_asb` constructor — wires `AsbTransport` into a
//! `Session`. Needs a thin shim that owns the AsbClient + delegates
//! `register_items`/`read`/`write`/`subscribe` to the corresponding
//! client method, mapping ASB result types (`ItemStatus`,
//! `RuntimeValue`) back to `mxaccess` types (`MxStatus`,
//! `DataChange`, `MxValue`).
//! * Subscription routing — `Session::subscribe` on ASB maps to a
//! `CreateSubscription` + `AddMonitoredItems` + `Publish`-callback
//! pipeline; the F25 subscription operations are not yet wired up.
use mxaccess_asb::AsbClient;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{Transport, TransportCapabilities, TransportKind};
/// `Transport` implementation for the ASB (`net.tcp` + binary-message-
/// encoder) data plane. Owns the underlying [`AsbClient`].
pub struct AsbTransport<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> {
client: AsbClient<T>,
}
impl<T: AsyncRead + AsyncWrite + Unpin + Send + 'static> AsbTransport<T> {
/// Build a transport from an already-constructed [`AsbClient`].
/// The client should typically have completed
/// `send_preamble().await? -> connect().await?` before being
/// wrapped — the F26 next-step `Session::connect_asb` will own that
/// orchestration.
pub fn new(client: AsbClient<T>) -> Self {
Self { client }
}
/// Surface the inner client. M5 / F26 step 2 wires concrete
/// operations through here.
pub fn client_mut(&mut self) -> &mut AsbClient<T> {
&mut self.client
}
/// Consume the transport and return the inner client. Useful when
/// the caller wants to issue raw IASBIDataV2 operations directly
/// before / after the Session-level orchestration kicks in.
pub fn into_client(self) -> AsbClient<T> {
self.client
}
}
/// Compile-time only: `AsbTransport` must be `Send + Sync + 'static`
/// (the `Transport` trait bound). Sync is provided by `AsbClient`'s
/// internal lack of interior mutability over non-Sync types — the
/// `AsyncRead + AsyncWrite + Unpin + Send` transport is the only
/// non-trivial constraint, and Tokio's `TcpStream` satisfies it.
const _: fn() = || {
fn assert_send_sync<T: Send + Sync + 'static>() {}
assert_send_sync::<AsbTransport<tokio::io::DuplexStream>>();
};
impl<T: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static> Transport for AsbTransport<T> {
fn capabilities(&self) -> TransportCapabilities {
TransportCapabilities {
// ASB has no proven buffered-batch DataUpdate equivalent.
buffered_subscribe: false,
// Activate/Suspend are NMX `INmxService2` primitives.
activate_suspend: false,
// No generic completion-frame channel on ASB.
operation_complete_frame: false,
}
}
fn kind(&self) -> TransportKind {
TransportKind::Asb
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use mxaccess_asb_nettcp::auth::{AsbAuthenticator, CryptoParameters};
fn make_authenticator() -> AsbAuthenticator {
AsbAuthenticator::new("test-passphrase", &CryptoParameters::defaults(), [0u8; 16]).unwrap()
}
#[test]
fn asb_transport_kind_is_asb() {
let (client_end, _peer) = tokio::io::duplex(64);
let client = AsbClient::new(client_end, make_authenticator(), "test://x/y");
let transport = AsbTransport::new(client);
assert_eq!(transport.kind(), TransportKind::Asb);
}
#[test]
fn asb_transport_capabilities_disable_buffered_and_activate_suspend() {
let (client_end, _peer) = tokio::io::duplex(64);
let client = AsbClient::new(client_end, make_authenticator(), "test://x/y");
let transport = AsbTransport::new(client);
let caps = transport.capabilities();
assert!(!caps.buffered_subscribe);
assert!(!caps.activate_suspend);
assert!(!caps.operation_complete_frame);
}
}