[M5] mxaccess-asb: F25 step 3 — response decoders + Read request body

Foundation for response decoding. Adds:

* `contracts::ItemStatus` — ports `AsbContracts.cs:639-722`. Wire
  layout matches `WriteToStream` exactly: Item (ItemIdentity binary)
  → Status (AsbStatus binary, from F24) → ErrorCode (u16) →
  ErrorCodeSpecified (u8 bool). Note this is NOT the DataMember
  declaration order — the binary serialiser hand-picks Item-first.

* `encode_item_status_array` / `decode_item_status_array` — same
  4-byte int32 count + per-element WriteToStream pattern as the
  ItemIdentity array codec.

* `operations::collect_asbidata_payloads(tokens, field_name)` — walks
  an NBFX token stream and pulls out `<{field}><ASBIData>{Bytes}
  </ASBIData></{field}>` payload bytes. Returns Vec<Vec<u8>> because
  some response shapes (ReadResponse) carry multiple ASBIData
  payloads (Status + Values).

* `decode_register_items_response` / `decode_unregister_items_response`
  — parse SOAP body NBFX tokens into typed RegisterItemsResponse /
  UnregisterItemsResponse. The optional ItemCapabilities array (XML-
  serialised, not binary) is recorded as a presence flag for now;
  decoding the individual ItemRegistration records is a follow-up.

* `build_read_request_body(items)` — simplest unary IASBIDataV2
  request, just `<ReadRequest xmlns="..."><Items><ASBIData>...
  </ASBIData></Items></ReadRequest>`.

* `OperationError` — typed error for response-decode failures
  (`MissingField { field }` and codec wraps).

9 new tests: ItemStatus round-trip (default + with id + with status
payload), ItemStatus array round-trip, RegisterItemsResponse
round-trip via synthetic body, ItemCapabilities presence detection,
UnregisterItemsResponse round-trip, multi-payload extraction (ReadResponse-
shape Status + Values), Read body shape correctness, MissingField
error when Status is absent.

Stubbed for next F25 iteration: Write / PublishWriteComplete /
CreateSubscription / AddMonitoredItems / DeleteMonitoredItems /
Publish builders, ReadResponse + WriteResponse decoders (need
WriteValue / RuntimeValue contract codecs), and the AsbClient
network loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 11:32:36 -04:00
parent a2b8989cbf
commit c4bf0a0a04
4 changed files with 429 additions and 6 deletions
+282 -1
View File
@@ -36,8 +36,11 @@
//! `InitializeArrayFromStream` shape.
use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken};
use mxaccess_codec::CodecError;
use crate::contracts::{ItemIdentity, encode_item_identity_array};
use crate::contracts::{
ItemIdentity, ItemStatus, decode_item_status_array, encode_item_identity_array,
};
/// Build the NBFX token stream for the body of a `RegisterItemsIn`
/// SOAP envelope. The caller wraps it via [`crate::SoapEnvelope`] +
@@ -75,6 +78,135 @@ pub fn build_register_items_request_body(
)
}
/// Build the NBFX token stream for `ReadIn`. Mirror of
/// `AsbContracts.cs:161-167`:
/// ```xml
/// <ReadRequest xmlns="urn:msg.data.asb.iom:2">
/// <Items><ASBIData>{int32 count + each ItemIdentity}</ASBIData></Items>
/// </ReadRequest>
/// ```
pub fn build_read_request_body(items: &[ItemIdentity]) -> Vec<NbfxToken> {
let payload = encode_item_identity_array(items);
asbidata_request_body("ReadRequest", &[BodyField::asbidata("Items", payload)])
}
/// Decoded `RegisterItemsResponse`. The `Status` array is binary-decoded
/// via `decode_item_status_array`. The optional `ItemCapabilities`
/// (`ItemRegistration[]`) field is **not** decoded here — that contract
/// is regular WCF XML serialization rather than the binary
/// `IAsbCustomSerializableType` fast-path, so it's deferred. Today we
/// just count whether it appeared in the body. See follow-up F28.
#[derive(Debug, Clone, PartialEq)]
pub struct RegisterItemsResponse {
pub status: Vec<ItemStatus>,
/// Whether the `<ItemCapabilities>` element appeared. Decoding the
/// individual `ItemRegistration` records is a future iteration.
pub item_capabilities_present: bool,
}
/// Decoded `UnregisterItemsResponse`. Single field: the per-item
/// `Status` array (`AsbContracts.cs:153-159`).
#[derive(Debug, Clone, PartialEq)]
pub struct UnregisterItemsResponse {
pub status: Vec<ItemStatus>,
}
/// Decode a `RegisterItemsResponse` SOAP body from the NBFX token
/// stream returned by [`crate::decode_envelope`].
pub fn decode_register_items_response(
body_tokens: &[NbfxToken],
) -> Result<RegisterItemsResponse, OperationError> {
let payloads = collect_asbidata_payloads(body_tokens, "Status");
let status_payload = payloads
.into_iter()
.next()
.ok_or(OperationError::MissingField { field: "Status" })?;
let status = decode_item_status_array(&status_payload)?;
let item_capabilities_present = find_element_named(body_tokens, "ItemCapabilities").is_some();
Ok(RegisterItemsResponse {
status,
item_capabilities_present,
})
}
/// Decode an `UnregisterItemsResponse` SOAP body.
pub fn decode_unregister_items_response(
body_tokens: &[NbfxToken],
) -> Result<UnregisterItemsResponse, OperationError> {
let payloads = collect_asbidata_payloads(body_tokens, "Status");
let status_payload = payloads
.into_iter()
.next()
.ok_or(OperationError::MissingField { field: "Status" })?;
let status = decode_item_status_array(&status_payload)?;
Ok(UnregisterItemsResponse { status })
}
/// Walk a SOAP body's NBFX token stream and pull out the
/// `<ASBIData>{Bytes}</ASBIData>` payload bytes for any element named
/// `field_name`. Returns `Vec<Vec<u8>>` because some response shapes
/// have multiple ASBIData payloads (e.g. `ReadResponse` has both
/// `Status` and `Values`).
///
/// Operates on token windows rather than tracking element depth — the
/// response shapes are shallow enough that name-keyed scanning is
/// reliable. Returns whichever payloads it finds; missing fields
/// surface as an empty `Vec`.
pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut idx = 0;
while idx < tokens.len() {
if let Some(NbfxToken::Element {
name: NbfxName::Inline(local),
..
}) = tokens.get(idx)
{
if local == field_name {
// Skip attributes / namespace decls.
let mut inner = idx + 1;
while matches!(
tokens.get(inner),
Some(NbfxToken::Attribute { .. })
| Some(NbfxToken::DefaultNamespace { .. })
| Some(NbfxToken::NamespaceDeclaration { .. })
) {
inner += 1;
}
if let Some(NbfxToken::Element {
name: NbfxName::Inline(asbidata),
..
}) = tokens.get(inner)
{
if asbidata == "ASBIData" {
if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) =
tokens.get(inner + 1)
{
out.push(payload.clone());
}
}
}
}
}
idx += 1;
}
out
}
fn find_element_named<'a>(tokens: &'a [NbfxToken], name: &str) -> Option<&'a NbfxToken> {
tokens.iter().find(|tok| {
matches!(tok, NbfxToken::Element { name: NbfxName::Inline(local), .. } if local == name)
})
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum OperationError {
#[error("response is missing required field {field}")]
MissingField { field: &'static str },
#[error("codec error decoding response: {0}")]
Codec(#[from] CodecError),
}
/// Build the NBFX token stream for `UnregisterItemsIn`. Mirror of
/// `AsbContracts.cs:145-159`:
/// ```xml
@@ -307,6 +439,155 @@ mod tests {
}
}
#[test]
fn read_request_body_uses_correct_outer_element_and_no_register_fields() {
let body = build_read_request_body(&[ItemIdentity::absolute_by_name("Tag.X")]);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ReadRequest"
));
// The Read contract has only `Items`. RequireId / RegisterOnly /
// Values are NOT present.
for tok in &body {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
assert!(local != "RequireId");
assert!(local != "RegisterOnly");
assert!(local != "Values");
}
}
}
#[test]
fn register_items_response_round_trips_status_array() {
use mxaccess_codec::AsbStatus;
let status = vec![
ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.A"),
status: AsbStatus {
count: 0,
payload: vec![],
},
error_code: 0,
error_code_specified: true,
},
ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.B"),
status: AsbStatus {
count: -1,
payload: vec![0xC0],
},
error_code: 7,
error_code_specified: true,
},
];
let payload = crate::contracts::encode_item_status_array(&status);
// Build a synthetic response body matching the wire shape.
let body = asbidata_request_body(
"RegisterItemsResponse",
&[BodyField::asbidata("Status", payload)],
);
let decoded = decode_register_items_response(&body).unwrap();
assert_eq!(decoded.status, status);
assert!(!decoded.item_capabilities_present);
}
#[test]
fn register_items_response_records_when_item_capabilities_appears() {
use mxaccess_codec::AsbStatus;
let status = vec![ItemStatus {
item: ItemIdentity::absolute_by_name("X"),
status: AsbStatus::default(),
error_code: 0,
error_code_specified: false,
}];
let status_payload = crate::contracts::encode_item_status_array(&status);
// Synthesise a body with both Status and ItemCapabilities elements.
let mut body = asbidata_request_body(
"RegisterItemsResponse",
&[BodyField::asbidata("Status", status_payload)],
);
// Splice in a synthetic ItemCapabilities element before the
// outer EndElement.
let close_idx = body.len() - 1;
body.insert(
close_idx,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ItemCapabilities".to_string()),
},
);
body.insert(close_idx + 1, NbfxToken::EndElement);
let decoded = decode_register_items_response(&body).unwrap();
assert_eq!(decoded.status, status);
assert!(decoded.item_capabilities_present);
}
#[test]
fn unregister_items_response_round_trips() {
use mxaccess_codec::AsbStatus;
let status = vec![ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.Y"),
status: AsbStatus {
count: 1,
payload: vec![0x40],
},
error_code: 0,
error_code_specified: false,
}];
let payload = crate::contracts::encode_item_status_array(&status);
let body = asbidata_request_body(
"UnregisterItemsResponse",
&[BodyField::asbidata("Status", payload)],
);
let decoded = decode_unregister_items_response(&body).unwrap();
assert_eq!(decoded.status, status);
}
#[test]
fn collect_asbidata_payloads_returns_empty_when_field_missing() {
let body = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Empty".to_string()),
},
NbfxToken::EndElement,
];
assert!(collect_asbidata_payloads(&body, "Status").is_empty());
}
#[test]
fn collect_asbidata_payloads_handles_multiple_fields() {
let body = asbidata_request_body(
"ReadResponse",
&[
BodyField::asbidata("Status", vec![1, 2, 3]),
BodyField::asbidata("Values", vec![4, 5, 6, 7]),
],
);
let status = collect_asbidata_payloads(&body, "Status");
let values = collect_asbidata_payloads(&body, "Values");
assert_eq!(status, vec![vec![1u8, 2, 3]]);
assert_eq!(values, vec![vec![4u8, 5, 6, 7]]);
}
#[test]
fn decode_register_items_response_returns_missing_field_when_status_absent() {
let body = asbidata_request_body("RegisterItemsResponse", &[]);
let err = decode_register_items_response(&body).unwrap_err();
assert!(matches!(
err,
OperationError::MissingField { field: "Status" }
));
}
#[test]
fn empty_items_array_still_produces_valid_envelope() {
let body = build_register_items_request_body(&[], false, false);