[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
+135 -1
View File
@@ -21,7 +21,7 @@
//! round-trip — so the per-type cost is small once the
//! [`ItemIdentity`] reference establishes it.
use mxaccess_codec::CodecError;
use mxaccess_codec::{AsbStatus, CodecError};
/// `ItemIdentity` per `AsbContracts.cs:533-633`. Wire layout:
///
@@ -121,6 +121,104 @@ impl ItemIdentity {
}
}
/// `ItemStatus` per `AsbContracts.cs:639-722`. Wire layout (from the
/// `WriteToStream` method at `cs:682-688`):
///
/// | Field | Codec |
/// |----------------|-----------------------------|
/// | `Item` | [`ItemIdentity`] binary form |
/// | `Status` | [`AsbStatus`] binary form |
/// | `ErrorCode` | u16 |
/// | `ErrorCodeSpecified` | u8 (bool) |
///
/// Note the field order on the wire (`Item` then `Status`) is **NOT**
/// the `[DataMember(Order = …)]` declared order — `WriteToStream`
/// hand-picks Item-first, Status-second, then the trailing pair.
/// We mirror that exactly.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ItemStatus {
pub item: ItemIdentity,
pub status: AsbStatus,
pub error_code: u16,
pub error_code_specified: bool,
}
impl ItemStatus {
pub fn encode_into(&self, out: &mut Vec<u8>) {
self.item.encode_into(out);
self.status.encode_into(out);
out.extend_from_slice(&self.error_code.to_le_bytes());
out.push(if self.error_code_specified { 1 } else { 0 });
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
self.encode_into(&mut out);
out
}
pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> {
let (item, item_consumed) = ItemIdentity::decode(input)?;
let mut cursor = item_consumed;
let status_tail = input.get(cursor..).ok_or(CodecError::ShortRead {
expected: 5,
actual: 0,
})?;
let (status, status_consumed) = AsbStatus::decode(status_tail)?;
cursor += status_consumed;
let error_code = read_u16_le(input, &mut cursor)?;
let error_code_specified = read_u8(input, &mut cursor)? != 0;
Ok((
Self {
item,
status,
error_code,
error_code_specified,
},
cursor,
))
}
}
/// Decode an array of `ItemStatus`es from the WCF custom-serializer
/// binary form (4-byte int32 count + each item's `WriteToStream`
/// output). Mirrors `ItemStatus.InitializeArrayFromStream`
/// (`cs:702-711`).
pub fn decode_item_status_array(input: &[u8]) -> Result<Vec<ItemStatus>, CodecError> {
let mut cursor = 0usize;
let count = read_i32_le(input, &mut cursor)?;
if count < 0 {
return Err(CodecError::Decode {
offset: 0,
reason: "negative item-status array count",
buffer_len: input.len(),
});
}
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 (item, consumed) = ItemStatus::decode(tail)?;
cursor += consumed;
out.push(item);
}
Ok(out)
}
/// Encode an array of `ItemStatus`es. Mirrors `ItemStatus.WriteArrayToStream`
/// (`cs:713-721`) — 4-byte int32 count + each element's `WriteToStream`.
pub fn encode_item_status_array(items: &[ItemStatus]) -> Vec<u8> {
let mut out = Vec::new();
let count = i32::try_from(items.len()).unwrap_or(i32::MAX);
out.extend_from_slice(&count.to_le_bytes());
for item in items {
item.encode_into(&mut out);
}
out
}
/// Encode an array of `IAsbCustomSerializableType` items per
/// `AsbDataCustomSerializer.WriteObjectContent` array branch
/// (`AsbContracts.cs:1583-1591` — calls `WriteArrayToStream` which
@@ -360,6 +458,42 @@ mod tests {
);
}
#[test]
fn item_status_round_trip() {
let s = ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.X"),
status: AsbStatus {
count: -1,
payload: vec![0xC0],
},
error_code: 0x1234,
error_code_specified: true,
};
let bytes = s.encode();
let (decoded, consumed) = ItemStatus::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, s);
}
#[test]
fn item_status_array_round_trip() {
let arr = vec![
ItemStatus::default(),
ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.A"),
status: AsbStatus {
count: 1,
payload: vec![0x01, 0x02],
},
error_code: 42,
error_code_specified: true,
},
];
let bytes = encode_item_status_array(&arr);
let decoded = decode_item_status_array(&bytes).unwrap();
assert_eq!(decoded, arr);
}
#[test]
fn item_identity_array_count_is_le_int32() {
let items = vec![ItemIdentity::default(); 7];