[M5] mxaccess-asb: F25 step 5 — KeepAlive + Read + one-way client ops

Extends AsbClient with one-way operation support (`IsOneWay = true`
in IASBIDataV2) plus the KeepAlive and Read operations.

Client API additions:
* `send_envelope_one_way(env)` — frames in SizedEnvelope, writes,
  returns immediately. No response read. Mirrors WCF's IsOneWay
  semantics for KeepAlive / Disconnect / AuthenticateMe.
* `send_signed_envelope_one_way(action, body, force_hmac)` —
  one-way variant that runs the body through F23's authenticator
  signing path so the ConnectionValidator header is attached.
* `keep_alive()` — sends an empty `KeepAliveRequest` with default
  signing. Used to keep the channel alive past the WCF inactivity
  timeout (30s default at `MxAsbDataClient.cs:683`).
* `read(items)` — sends a signed Read envelope, decodes
  ReadResponse with both Status and Values arrays.

Operations API additions:
* `build_keep_alive_request_body()` — empty wrapper element +
  asb.contracts.messages namespace. Mirror of `AsbContracts.cs:117`
  (`public sealed class KeepAlive : ConnectedRequest;`).
* `ReadResponse { status: Vec<ItemStatus>, values: Vec<RuntimeValue> }`
  per `AsbContracts.cs:169-179`.
* `decode_read_response(body_tokens)` — pulls both ASBIData
  payloads, decodes Status as ItemStatus[], decodes Values via
  `decode_runtime_value_array` (4-byte int32 count + per-element
  `RuntimeValue::decode` from F24).

5 new tests:
* KeepAlive body shape (empty wrapper, correct namespace).
* ReadResponse decoder round-trip with both Status and Values.
* ReadResponse decoder graceful handling when Values is absent
  (returns empty vec).
* End-to-end client::keep_alive — peer drains SizedEnvelope but
  doesn't respond; client returns Ok().
* End-to-end client::read — peer responds with synthetic
  ReadResponse, client recovers Values[0].timestamp_binary == 1234
  and Values[0].status round-trip.

Stubbed for next F25 iterations:
* AsbClient::connect — DH Connect + AuthenticateMe handshake. Needs
  ConnectRequest / ConnectResponse builders (regular WCF XML, not
  the IAsbCustomSerializableType fast-path).
* Write / PublishWriteComplete / CreateSubscription /
  AddMonitoredItems / Publish / Disconnect operation wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 11:42:39 -04:00
parent 1e59249662
commit 9b8133f725
4 changed files with 401 additions and 8 deletions
+170 -1
View File
@@ -36,7 +36,7 @@
//! `InitializeArrayFromStream` shape.
use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken};
use mxaccess_codec::CodecError;
use mxaccess_codec::{CodecError, RuntimeValue};
use crate::contracts::{
ItemIdentity, ItemStatus, decode_item_status_array, encode_item_identity_array,
@@ -90,6 +90,99 @@ pub fn build_read_request_body(items: &[ItemIdentity]) -> Vec<NbfxToken> {
asbidata_request_body("ReadRequest", &[BodyField::asbidata("Items", payload)])
}
/// Build the NBFX token stream for a `KeepAliveIn` request body. The
/// `KeepAlive` contract has no body fields beyond the inherited
/// `ConnectionValidator` header, so the body is just the empty wrapper
/// element (`AsbContracts.cs:117` — `public sealed class KeepAlive :
/// ConnectedRequest;`).
///
/// One-way op (`IsOneWay = true` at `AsbContracts.cs:26`) — caller
/// uses [`crate::AsbClient::send_envelope_one_way`].
pub fn build_keep_alive_request_body() -> Vec<NbfxToken> {
vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("KeepAliveRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(MESSAGES_NS.to_string()),
},
NbfxToken::EndElement,
]
}
const MESSAGES_NS: &str = "http://asb.contracts.messages/20111111";
/// Decode a `ReadResponse` SOAP body. Mirrors the decode path of
/// `MxAsbDataClient.DecodeVariant` (`MxAsbDataClient.cs:713-825`)
/// applied to each `<Values>` `<ASBIData>` payload.
///
/// `Values` are decoded as `RuntimeValue` (timestamp + variant + status
/// per `AsbContracts.cs:741-791`) using the F24 codec. `Status` is the
/// per-item operation status array.
#[derive(Debug, Clone, PartialEq)]
pub struct ReadResponse {
pub status: Vec<ItemStatus>,
pub values: Vec<RuntimeValue>,
}
/// Decode a `ReadResponse` SOAP body from the NBFX tokens returned by
/// [`crate::decode_envelope`]. Both `Status` and `Values` arrive as
/// `<ASBIData>` payloads; we decode the binary form of each.
pub fn decode_read_response(body_tokens: &[NbfxToken]) -> Result<ReadResponse, OperationError> {
let status_payload = collect_asbidata_payloads(body_tokens, "Status")
.into_iter()
.next()
.ok_or(OperationError::MissingField { field: "Status" })?;
let status = decode_item_status_array(&status_payload)?;
let values = match collect_asbidata_payloads(body_tokens, "Values")
.into_iter()
.next()
{
Some(payload) => decode_runtime_value_array(&payload)?,
None => Vec::new(),
};
Ok(ReadResponse { status, values })
}
/// Decode a `RuntimeValue[]` array from the WCF custom-serializer
/// binary form (4-byte int32 count + each value's `WriteToStream`).
/// Mirrors `RuntimeValue.InitializeArrayFromStream` (`AsbContracts.cs:771-780`).
fn decode_runtime_value_array(input: &[u8]) -> Result<Vec<RuntimeValue>, CodecError> {
if input.len() < 4 {
return Err(CodecError::ShortRead {
expected: 4,
actual: input.len(),
});
}
let mut count_buf = [0u8; 4];
if let Some(slice) = input.get(0..4) {
count_buf.copy_from_slice(slice);
}
let count = i32::from_le_bytes(count_buf);
if count < 0 {
return Err(CodecError::Decode {
offset: 0,
reason: "negative runtime-value array count",
buffer_len: input.len(),
});
}
let mut cursor = 4usize;
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
let tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 1,
actual: 0,
})?;
let (rv, consumed) = RuntimeValue::decode(tail)?;
cursor += consumed;
out.push(rv);
}
Ok(out)
}
/// Decoded `RegisterItemsResponse`. The `Status` array is binary-decoded
/// via `decode_item_status_array`. The optional `ItemCapabilities`
/// (`ItemRegistration[]`) field is **not** decoded here — that contract
@@ -588,6 +681,82 @@ mod tests {
));
}
#[test]
fn keep_alive_body_is_empty_wrapper_with_namespace() {
let body = build_keep_alive_request_body();
assert_eq!(body.len(), 3);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "KeepAliveRequest"
));
assert!(matches!(
&body[1],
NbfxToken::DefaultNamespace { value: NbfxText::Chars(ns) }
if ns == "http://asb.contracts.messages/20111111"
));
assert!(matches!(&body[2], NbfxToken::EndElement));
}
#[test]
fn read_response_decodes_status_and_values() {
use mxaccess_codec::{AsbStatus, AsbVariant, RuntimeValue};
let status = vec![ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.A"),
status: AsbStatus::default(),
error_code: 0,
error_code_specified: true,
}];
let values = vec![RuntimeValue {
timestamp_binary: 0x0123_4567_89AB_CDEF,
timestamp_specified: true,
value: AsbVariant::from_i32(42),
status: AsbStatus {
count: 0,
payload: vec![],
},
}];
// Encode the values array using the same int32-count + per-value
// shape that `RuntimeValue.WriteArrayToStream` emits.
let mut values_payload = i32::try_from(values.len())
.unwrap_or(i32::MAX)
.to_le_bytes()
.to_vec();
for v in &values {
v.encode_into(&mut values_payload);
}
let status_payload = crate::contracts::encode_item_status_array(&status);
let body = asbidata_request_body(
"ReadResponse",
&[
BodyField::asbidata("Status", status_payload),
BodyField::asbidata("Values", values_payload),
],
);
let decoded = decode_read_response(&body).unwrap();
assert_eq!(decoded.status, status);
assert_eq!(decoded.values, values);
}
#[test]
fn read_response_with_no_values_returns_empty_vec() {
use mxaccess_codec::AsbStatus;
let status = vec![ItemStatus {
item: ItemIdentity::absolute_by_name("X"),
status: AsbStatus::default(),
error_code: 0,
error_code_specified: true,
}];
let payload = crate::contracts::encode_item_status_array(&status);
let body = asbidata_request_body("ReadResponse", &[BodyField::asbidata("Status", payload)]);
let decoded = decode_read_response(&body).unwrap();
assert_eq!(decoded.status, status);
assert!(decoded.values.is_empty());
}
#[test]
fn empty_items_array_still_produces_valid_envelope() {
let body = build_register_items_request_body(&[], false, false);