Files
mxaccess/rust/crates/mxaccess-asb/src/operations.rs
T
Joseph Doherty 3b09297b27 [M5] live-probe iteration 1 — major wire-byte reconciliation fixes
First live-test cycle against AVEVA on this box. Comparing the .NET
probe's `--dump-messages` XML output against our NBFX-encoded
envelope surfaced six structural bugs in the F25 envelope/operations
layer. All fixed; tests passing (702 workspace).

Fixes (all backed by the .NET dump as ground truth):

1. **`mustUnderstand` attribute name** — NBFS dict id was 116
   (`MustUnderstand`, capital-M, a different SOAP token); SOAP 1.2
   spec uses lowercase `mustUnderstand` at id 0. Sending the wrong
   one triggered a WCF parse fault that surfaced as TCP RST.

2. **Missing `<a:MessageID>` header** — WCF's default binding
   requires MessageID for two-way operations. We now auto-generate
   `urn:uuid:<v4>` per envelope via a small inline `make_random_uuid_v4`
   helper (no `uuid` crate dep).

3. **Missing `<a:ReplyTo>` anonymous header** — WCF's
   BinaryMessageEncoder always emits `<a:ReplyTo><a:Address>...
   addressing/anonymous</a:Address></a:ReplyTo>` for two-way ops.

4. **ConnectionValidator field names + namespace** — we were
   emitting PascalCase `<ConnectionId>` etc. .NET's WCF
   DataContractSerializer uses the private backing-field names
   (`<connectionIdField xmlns="...ASBContract">guid</connectionIdField>`)
   per `[DataMember(Name = "fooField")]`. Added the
   `xmlns:i="...XMLSchema-instance"` declaration WCF emits
   alongside (even when no `i:nil` is used). Decoder now accepts
   both PascalCase (legacy tests) and DataContract field names.

5. **`<ASBIData>` over-wrapping** — we were emitting
   `<Items><ASBIData>{bytes}</ASBIData></Items>`. .NET's
   `AsbDataCustomSerializer.WriteStartObject` (`AsbContracts.cs:
   1561-1572`) REPLACES the field's outer element with `<ASBIData>`
   directly — there's no `<Items>` wrapper on the wire. Fixed by
   collapsing `BodyField::AsbiDataElement` to emit just `<ASBIData>`
   without the named outer element. The `name` field is retained
   for self-documentation.

6. **`collect_asbidata_payloads` API** — was keyed by field name
   (`Status` / `Values`); now positional (`payloads[0]`,
   `payloads.get(1)`) since the wrapper element is gone. All seven
   response decoders updated.

Plus tooling for the live-probe loop:
* `tools/Get-AsbPassphrase.ps1` — DPAPI loader that auto-discovers
  the solution name + reads the sharedsecret + decrypts it. Sets
  $env:MX_ASB_PASSPHRASE / MX_ASB_HOST / MX_ASB_VIA / MX_LIVE.
  Lowercase via-host (WCF SMSvcHost is case-sensitive on the URL
  host segment).
* `examples/asb-preamble-probe.rs` — diagnostic that connects,
  runs the preamble, captures the PreambleAck, then sends a
  synthetic ConnectRequest and dumps both directions as hex. Used
  to bisect the wire-byte deltas above.
* `examples/asb-subscribe.rs` port default fixed (5074 → 808 —
  WCF's NetTcpPortSharing/SMSvcHost listener confirmed via
  Get-NetTCPConnection).

**Status**: preamble + PreambleAck round-trip works end-to-end
against the live AVEVA install (verified via probe). The
post-preamble Connect SOAP envelope still gets TCP RST'd — the six
structural fixes above are necessary but not yet sufficient. Next
iteration needs binary wire capture (Wireshark + Npcap loopback,
or a TCP-relay middleman) to compare the .NET probe's BinaryMessageEncoder
output byte-for-byte with ours and find the remaining delta(s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:06:48 -04:00

2256 lines
80 KiB
Rust

//! Per-operation request / response NBFX-token builders for
//! `IASBIDataV2`.
//!
//! Each `IAsbCustomSerializableType`-decorated field in a request
//! contract is serialised by WCF's `AsbDataCustomSerializer`
//! (`AsbContracts.cs:1561-1599`) as:
//!
//! ```xml
//! <FieldName xmlns="urn:msg.data.asb.iom:2">
//! <ASBIData>{base64-binary}</ASBIData>
//! </FieldName>
//! ```
//!
//! The `<ASBIData>` element body is the binary `WriteToStream` /
//! `WriteArrayToStream` output, written via `WriteBase64`. In the NBFX
//! wire form we get from the WCF binary encoder, `WriteBase64` emits a
//! `Bytes8/16/32Text` record (raw binary, NOT base64 text — base64 is
//! the XML-text representation of the same bytes).
//!
//! ## Scope this iteration (F25 step 2)
//!
//! Implements:
//! * [`build_register_items_request_body`] — `RegisterItems` request
//! contract per `AsbContracts.cs:119-143`.
//! * [`build_unregister_items_request_body`] — `UnregisterItems`
//! request per `cs:145-159`.
//!
//! Stubbed for next F25 iteration:
//! * `Read`, `Write`, `PublishWriteComplete`, `CreateSubscription`,
//! `AddMonitoredItems`, `DeleteMonitoredItems`, `Publish`. Each
//! follows the same NBFX-token pattern; the per-operation cost is
//! small once the `RegisterItems` reference is set.
//! * Response decoders. Same pattern in reverse: the reply envelope's
//! body tokens carry a per-operation outer element wrapping
//! `<ASBIData>` Bytes records, each decoded via the corresponding
//! `InitializeArrayFromStream` shape.
use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken};
use mxaccess_codec::{CodecError, RuntimeValue};
use crate::contracts::{
ItemIdentity, ItemStatus, MonitoredItemValue, decode_item_status_array,
decode_monitored_item_value_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`] +
/// [`crate::encode_envelope`].
///
/// Wire shape (from `AsbContracts.cs:119-143`):
/// ```xml
/// <RegisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
/// <Items>
/// <ASBIData>{int32 count + each ItemIdentity binary}</ASBIData>
/// </Items>
/// <RequireId>true|false</RequireId>
/// <RegisterOnly>true|false</RegisterOnly>
/// </RegisterItemsRequest>
/// ```
///
/// NOTE: WCF emits the wrapper element's `xmlns` declaration as a
/// default-namespace attribute (`<RegisterItemsRequest
/// xmlns="urn:...">`). NBFX represents this as a
/// `DefaultNamespace`-attribute token immediately after the element
/// open.
pub fn build_register_items_request_body(
items: &[ItemIdentity],
require_id: bool,
register_only: bool,
) -> Vec<NbfxToken> {
let payload = encode_item_identity_array(items);
asbidata_request_body(
"RegisterItemsRequest",
&[
BodyField::asbidata("Items", payload),
BodyField::boolean("RequireId", require_id),
BodyField::boolean("RegisterOnly", register_only),
],
)
}
/// 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)])
}
/// Build the NBFX token stream for a `ConnectIn` request body.
/// `ConnectRequest` is the first operation a fresh ASB session sends —
/// it carries the consumer's DH public key + a fresh `ConnectionId`
/// GUID. Sent **unsigned** (no `ConnectionValidator` header) since the
/// authenticator hasn't received the service's public key yet.
///
/// Wire shape (mirrors `AsbContracts.cs:78-86`):
/// ```xml
/// <ConnectRequest xmlns="http://asb.contracts.messages/20111111">
/// <ConnectionId>{guid-text}</ConnectionId>
/// <ConsumerPublicKey>
/// <Data>{public-key-bytes}</Data>
/// </ConsumerPublicKey>
/// </ConnectRequest>
/// ```
///
/// **Wire-byte caveat**: WCF's XML serialiser emits the `<Data>`
/// `byte[]` member via `WriteBase64`, which the binary-message encoder
/// represents as a `BytesXText` NBFX record (raw binary, not base64
/// text). For services using DataContract serialisation, the inner
/// `PublicKey` element may also receive an `xsi:type` attribute or a
/// distinct namespace — until a live capture confirms the exact
/// wire form, this builder uses the simplest plausible shape. F25
/// live-probe iteration will reconcile.
pub fn build_connect_request_body(
connection_id: [u8; 16],
consumer_public_key: &[u8],
) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(MESSAGES_NS.to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectionId".to_string()),
},
NbfxToken::Text(NbfxText::Chars(crate::envelope::format_uuid_for_test(
&connection_id,
))),
NbfxToken::EndElement, // </ConnectionId>
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConsumerPublicKey".to_string()),
},
];
tokens.extend(public_key_data_field(consumer_public_key));
tokens.push(NbfxToken::EndElement); // </ConsumerPublicKey>
tokens.push(NbfxToken::EndElement); // </ConnectRequest>
tokens
}
/// Build the NBFX token stream for `DisconnectIn`. Mirrors
/// `AsbContracts.cs:109-114`:
/// ```xml
/// <DisconnectRequest xmlns="http://asb.contracts.messages/20111111">
/// <ConsumerAuthenticationData>
/// <Data>{encrypted-bytes}</Data>
/// <InitializationVector>{iv-bytes}</InitializationVector>
/// </ConsumerAuthenticationData>
/// </DisconnectRequest>
/// ```
///
/// One-way op (`IsOneWay = true` at `AsbContracts.cs:22`); typically
/// signed with the connection validator (no `forceHmac`) right before
/// closing the channel.
pub fn build_disconnect_request_body(
consumer_data: &[u8],
initialization_vector: &[u8],
) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("DisconnectRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(MESSAGES_NS.to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConsumerAuthenticationData".to_string()),
},
];
tokens.extend(authentication_data_fields(
consumer_data,
initialization_vector,
));
tokens.push(NbfxToken::EndElement); // </ConsumerAuthenticationData>
tokens.push(NbfxToken::EndElement); // </DisconnectRequest>
tokens
}
/// Build the NBFX token stream for `AuthenticateMeIn`. Sent
/// **one-way** + **signed with `forceHmac=true`** per
/// `MxAsbDataClient.cs:106-111`:
/// ```xml
/// <AuthenticateMeRequest xmlns="http://asb.contracts.messages/20111111">
/// <ConsumerAuthenticationData>
/// <Data>{encrypted-bytes}</Data>
/// <InitializationVector>{iv-bytes}</InitializationVector>
/// </ConsumerAuthenticationData>
/// </AuthenticateMeRequest>
/// ```
pub fn build_authenticate_me_request_body(
consumer_data: &[u8],
initialization_vector: &[u8],
) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("AuthenticateMeRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(MESSAGES_NS.to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConsumerAuthenticationData".to_string()),
},
];
tokens.extend(authentication_data_fields(
consumer_data,
initialization_vector,
));
tokens.push(NbfxToken::EndElement); // </ConsumerAuthenticationData>
tokens.push(NbfxToken::EndElement); // </AuthenticateMeRequest>
tokens
}
fn public_key_data_field(data: &[u8]) -> Vec<NbfxToken> {
vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
NbfxToken::EndElement,
]
}
fn authentication_data_fields(data: &[u8], iv: &[u8]) -> Vec<NbfxToken> {
vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
NbfxToken::EndElement,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("InitializationVector".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(iv.to_vec())),
NbfxToken::EndElement,
]
}
/// Decoded `ConnectResponse`. Mirrors `AsbContracts.cs:88-100`.
#[derive(Debug, Clone, PartialEq)]
pub struct ConnectResponse {
/// Service public key bytes (`PublicKey.Data`). Required.
pub service_public_key: Vec<u8>,
/// Service authentication data — encrypted blob + IV. Optional;
/// some service versions omit it.
pub service_authentication_data: Option<AuthenticationDataBytes>,
/// Negotiated connection lifetime (xs:duration string like
/// `"PT60M:V2"`). The `:V2` suffix toggles Apollo signing in F23.
pub connection_lifetime: Option<String>,
}
/// `AuthenticationData` payload (`Data` + `InitializationVector`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticationDataBytes {
pub data: Vec<u8>,
pub initialization_vector: Vec<u8>,
}
/// Decode a `ConnectResponse` SOAP body from the NBFX tokens returned
/// by [`crate::decode_envelope`].
pub fn decode_connect_response(
body_tokens: &[NbfxToken],
dynamic: &mxaccess_asb_nettcp::nbfx::DynamicDictionary,
) -> Result<ConnectResponse, OperationError> {
let service_public_key = find_inline_bytes(body_tokens, &["ServicePublicKey", "Data"]).ok_or(
OperationError::MissingField {
field: "ServicePublicKey/Data",
},
)?;
let service_authentication_data =
find_authentication_data(body_tokens, "ServiceAuthenticationData");
let connection_lifetime = find_inline_text(body_tokens, "ConnectionLifetime", dynamic);
Ok(ConnectResponse {
service_public_key,
service_authentication_data,
connection_lifetime,
})
}
/// Walk `tokens` and find the inner `Bytes` payload of an element-path
/// like `["ServicePublicKey", "Data"]` (i.e. `<ServicePublicKey><Data>{Bytes}</Data></ServicePublicKey>`).
/// Permissive — skips attributes / namespace decls between element opens.
fn find_inline_bytes(tokens: &[NbfxToken], path: &[&str]) -> Option<Vec<u8>> {
let mut idx = 0;
let mut path_idx = 0;
while let Some(tok) = tokens.get(idx) {
if path_idx == path.len() {
// Should be a Text(Bytes) here (after skipping attribute-like tokens).
let mut inner = idx;
while matches!(
tokens.get(inner),
Some(NbfxToken::Attribute { .. })
| Some(NbfxToken::DefaultNamespace { .. })
| Some(NbfxToken::NamespaceDeclaration { .. })
) {
inner += 1;
}
if let Some(NbfxToken::Text(NbfxText::Bytes(bytes))) = tokens.get(inner) {
return Some(bytes.clone());
}
return None;
}
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if let Some(target) = path.get(path_idx) {
if local == target {
path_idx += 1;
}
}
}
idx += 1;
}
None
}
fn find_authentication_data(
tokens: &[NbfxToken],
outer_name: &str,
) -> Option<AuthenticationDataBytes> {
// Find the outer element, then within its scope locate Data and IV.
let mut idx = 0;
while let Some(tok) = tokens.get(idx) {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == outer_name {
let data = find_inline_bytes(tokens.get(idx + 1..)?, &["Data"]).unwrap_or_default();
let iv = find_inline_bytes(tokens.get(idx + 1..)?, &["InitializationVector"])
.unwrap_or_default();
if data.is_empty() && iv.is_empty() {
return None;
}
return Some(AuthenticationDataBytes {
data,
initialization_vector: iv,
});
}
}
idx += 1;
}
None
}
fn find_inline_text(
tokens: &[NbfxToken],
name: &str,
dynamic: &mxaccess_asb_nettcp::nbfx::DynamicDictionary,
) -> Option<String> {
let mut idx = 0;
while let Some(tok) = tokens.get(idx) {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == name {
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::Text(text)) = tokens.get(inner) {
return text.resolve(dynamic);
}
}
}
idx += 1;
}
None
}
// ---- PublishWriteComplete + DeleteMonitoredItems (F25 step 10) ----------
/// Build the NBFX token stream for a `PublishWriteCompleteIn` request
/// body. Empty wrapper per `AsbContracts.cs:204-205`
/// (`PublishWriteCompleteRequest : ConnectedRequest;` — no body fields
/// beyond the inherited ConnectionValidator header).
pub fn build_publish_write_complete_request_body() -> Vec<NbfxToken> {
vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("PublishWriteCompleteRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(IOM_NS.to_string()),
},
NbfxToken::EndElement,
]
}
/// Decoded `PublishWriteCompleteResponse`. Mirrors `AsbContracts.cs:207-213`.
///
/// The inner `ItemWriteComplete` records are regular WCF DataContract
/// (not the binary fast-path), so per-element decode is deferred to a
/// later iteration once a live capture confirms the WCF XML wire form.
/// For now this just counts how many `<ItemWriteComplete>` elements
/// appeared in the body — enough for callers to detect "complete-write
/// callback fired" without parsing the per-write WriteHandle/Status.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PublishWriteCompleteResponse {
pub complete_writes_count: usize,
}
pub fn decode_publish_write_complete_response(
body_tokens: &[NbfxToken],
) -> Result<PublishWriteCompleteResponse, OperationError> {
let count = body_tokens
.iter()
.filter(|tok| {
matches!(
tok,
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ItemWriteComplete"
)
})
.count();
Ok(PublishWriteCompleteResponse {
complete_writes_count: count,
})
}
/// Build the NBFX token stream for `DeleteMonitoredItemsIn`. Mirrors
/// `AsbContracts.cs:268-277`. Same MonitoredItem shape as
/// AddMonitoredItems but no RequireId field.
pub fn build_delete_monitored_items_request_body(
subscription_id: i64,
items: &[MinimalMonitoredItem],
) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("DeleteMonitoredItemsRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(IOM_NS.to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("SubscriptionId".to_string()),
},
NbfxToken::Text(NbfxText::Int64(subscription_id)),
NbfxToken::EndElement,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Items".to_string()),
},
];
for item in items {
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("MonitoredItem".to_string()),
});
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Item".to_string()),
});
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ASBIData".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bytes(item.item.encode())));
tokens.push(NbfxToken::EndElement); // </ASBIData>
tokens.push(NbfxToken::EndElement); // </Item>
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("SampleInterval".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Int64(
item.sample_interval as i64,
)));
tokens.push(NbfxToken::EndElement);
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Buffered".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bool(item.buffered)));
tokens.push(NbfxToken::EndElement);
tokens.push(NbfxToken::EndElement); // </MonitoredItem>
}
tokens.push(NbfxToken::EndElement); // </Items>
tokens.push(NbfxToken::EndElement); // </DeleteMonitoredItemsRequest>
tokens
}
/// Decoded `DeleteMonitoredItemsResponse`. Single Status array per
/// `AsbContracts.cs:279-285`.
#[derive(Debug, Clone, PartialEq)]
pub struct DeleteMonitoredItemsResponse {
pub status: Vec<ItemStatus>,
}
pub fn decode_delete_monitored_items_response(
body_tokens: &[NbfxToken],
) -> Result<DeleteMonitoredItemsResponse, OperationError> {
let payload = collect_asbidata_payloads(body_tokens)
.into_iter()
.next()
.ok_or(OperationError::MissingField { field: "Status" })?;
let status = decode_item_status_array(&payload)?;
Ok(DeleteMonitoredItemsResponse { status })
}
// ---- Write operation (F25 step 9) ---------------------------------------
/// Minimal `WriteValue` shape carrying just the AsbVariant payload. The
/// full .NET `WriteValue` (`AsbContracts.cs:793-894`) also has optional
/// ArrayElementIndex, Comment, HasQT, Status, and Timestamp fields.
/// Those are deferred to a later F25 iteration once a live capture
/// confirms the WCF DataContract XML wire form.
///
/// Note: the .NET `WriteValue` does NOT carry `Item` directly —
/// `WriteBasicRequest` carries `Items[]` + `Values[]` as parallel
/// arrays. We mirror that wire shape — see [`build_write_request_body`].
#[derive(Debug, Clone, PartialEq)]
pub struct MinimalWriteValue {
pub value: mxaccess_codec::AsbVariant,
}
impl MinimalWriteValue {
pub fn new(value: mxaccess_codec::AsbVariant) -> Self {
Self { value }
}
}
/// Build the NBFX token stream for a `WriteIn` request body. Mirrors
/// `AsbContracts.cs:181-194`. The Items array uses the
/// IAsbCustomSerializableType binary fast-path (`<ASBIData>` Bytes
/// record); the Values array is per-WriteValue regular XML — though
/// the Variant inside each WriteValue/Value field IS
/// IAsbCustomSerializableType so it gets `<ASBIData>` wrapping.
///
/// **Wire-byte caveat**: optional ArrayElementIndex / Comment / HasQT
/// / Status / Timestamp fields are not emitted. Live-probe iteration
/// will reconcile.
pub fn build_write_request_body(
items: &[ItemIdentity],
values: &[MinimalWriteValue],
write_handle: u32,
) -> Vec<NbfxToken> {
let items_payload = encode_item_identity_array(items);
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("WriteBasicRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(IOM_NS.to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Items".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ASBIData".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(items_payload)),
NbfxToken::EndElement, // </ASBIData>
NbfxToken::EndElement, // </Items>
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Values".to_string()),
},
];
for v in values {
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("WriteValue".to_string()),
});
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Value".to_string()),
});
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ASBIData".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bytes(v.value.encode())));
tokens.push(NbfxToken::EndElement); // </ASBIData>
tokens.push(NbfxToken::EndElement); // </Value>
tokens.push(NbfxToken::EndElement); // </WriteValue>
}
tokens.push(NbfxToken::EndElement); // </Values>
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("WriteHandle".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Int32(write_handle as i32)));
tokens.push(NbfxToken::EndElement);
tokens.push(NbfxToken::EndElement); // </WriteBasicRequest>
tokens
}
/// Decoded `WriteResponse`. Mirrors `AsbContracts.cs:196-202` — just
/// the per-item Status array.
#[derive(Debug, Clone, PartialEq)]
pub struct WriteResponse {
pub status: Vec<ItemStatus>,
}
pub fn decode_write_response(body_tokens: &[NbfxToken]) -> Result<WriteResponse, OperationError> {
let payload = collect_asbidata_payloads(body_tokens)
.into_iter()
.next()
.ok_or(OperationError::MissingField { field: "Status" })?;
let status = decode_item_status_array(&payload)?;
Ok(WriteResponse { status })
}
// ---- Subscription operations (F25 step 8) -------------------------------
/// Build the NBFX token stream for a `CreateSubscriptionIn` request
/// body. Mirrors `AsbContracts.cs:215-223`:
/// ```xml
/// <CreateSubscriptionRequest xmlns="urn:msg.data.asb.iom:2">
/// <MaxQueueSize>{long}</MaxQueueSize>
/// <SampleInterval>{ulong}</SampleInterval>
/// </CreateSubscriptionRequest>
/// ```
pub fn build_create_subscription_request_body(
max_queue_size: i64,
sample_interval: u64,
) -> Vec<NbfxToken> {
asbidata_request_body(
"CreateSubscriptionRequest",
&[
BodyField::int64("MaxQueueSize", max_queue_size),
BodyField::uint64("SampleInterval", sample_interval),
],
)
}
/// Build the NBFX token stream for `DeleteSubscriptionIn`. Mirrors
/// `AsbContracts.cs:232-237`:
/// ```xml
/// <DeleteSubscriptionRequest xmlns="urn:msg.data.asb.iom:2">
/// <SubscriptionId>{long}</SubscriptionId>
/// </DeleteSubscriptionRequest>
/// ```
pub fn build_delete_subscription_request_body(subscription_id: i64) -> Vec<NbfxToken> {
asbidata_request_body(
"DeleteSubscriptionRequest",
&[BodyField::int64("SubscriptionId", subscription_id)],
)
}
/// Build the NBFX token stream for `PublishIn`. Mirrors
/// `AsbContracts.cs:287-292`:
/// ```xml
/// <PublishRequest xmlns="urn:msg.data.asb.iom:2">
/// <SubscriptionId>{long}</SubscriptionId>
/// </PublishRequest>
/// ```
pub fn build_publish_request_body(subscription_id: i64) -> Vec<NbfxToken> {
asbidata_request_body(
"PublishRequest",
&[BodyField::int64("SubscriptionId", subscription_id)],
)
}
/// Build the NBFX token stream for `AddMonitoredItemsIn`. Mirrors
/// `AsbContracts.cs:242-254` — the **minimal** form that supplies only
/// the required `Item` + `SampleInterval` per `MonitoredItem`. Optional
/// `Active` / `TimeDeadband` / `ValueDeadband` / `UserData` / `Buffered`
/// fields are NOT emitted (their `*Specified=false` in WCF would
/// suppress them anyway). Wire shape:
///
/// ```xml
/// <AddMonitoredItemsRequest xmlns="urn:msg.data.asb.iom:2">
/// <SubscriptionId>{long}</SubscriptionId>
/// <Items xmlns:i="...XMLSchema-instance">
/// <MonitoredItem>
/// <Item><ASBIData>{ItemIdentity binary}</ASBIData></Item>
/// <SampleInterval>{ulong}</SampleInterval>
/// <Buffered>false</Buffered>
/// </MonitoredItem>
/// ...
/// </Items>
/// <RequireId>{bool}</RequireId>
/// </AddMonitoredItemsRequest>
/// ```
///
/// **Wire-byte caveat**: `MonitoredItem` is a regular WCF DataContract
/// (not `IAsbCustomSerializableType`). The exact element-ordering /
/// xsi:type attribute / namespace prefix layout depends on which
/// serializer WCF picks; this builder emits the most plausible shape
/// and the live-probe iteration will reconcile. F25 follow-up will
/// expand to the full optional-field set once a capture is available.
pub fn build_add_monitored_items_request_body(
subscription_id: i64,
items: &[MinimalMonitoredItem],
require_id: bool,
) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("AddMonitoredItemsRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(IOM_NS.to_string()),
},
// <SubscriptionId>
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("SubscriptionId".to_string()),
},
NbfxToken::Text(NbfxText::Int64(subscription_id)),
NbfxToken::EndElement,
// <Items>
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Items".to_string()),
},
];
for item in items {
// <MonitoredItem>
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("MonitoredItem".to_string()),
});
// <Item><ASBIData>{ItemIdentity binary}</ASBIData></Item>
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Item".to_string()),
});
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ASBIData".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bytes(item.item.encode())));
tokens.push(NbfxToken::EndElement); // </ASBIData>
tokens.push(NbfxToken::EndElement); // </Item>
// <SampleInterval>
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("SampleInterval".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Int64(
item.sample_interval as i64,
)));
tokens.push(NbfxToken::EndElement);
// <Buffered>
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Buffered".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bool(item.buffered)));
tokens.push(NbfxToken::EndElement);
tokens.push(NbfxToken::EndElement); // </MonitoredItem>
}
tokens.push(NbfxToken::EndElement); // </Items>
// <RequireId>
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("RequireId".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bool(require_id)));
tokens.push(NbfxToken::EndElement);
tokens.push(NbfxToken::EndElement); // </AddMonitoredItemsRequest>
tokens
}
/// Minimal `MonitoredItem` shape covering just `Item`, `SampleInterval`,
/// and `Buffered`. The full .NET `MonitoredItem` (`AsbContracts.cs:936-1030`)
/// also has optional Active, TimeDeadband, ValueDeadband, and UserData
/// fields. Those are deferred to a later F25 iteration once a live
/// capture confirms the wire-byte form.
#[derive(Debug, Clone, PartialEq)]
pub struct MinimalMonitoredItem {
pub item: ItemIdentity,
pub sample_interval: u64,
pub buffered: bool,
}
impl MinimalMonitoredItem {
pub fn new(item: ItemIdentity, sample_interval: u64) -> Self {
Self {
item,
sample_interval,
buffered: false,
}
}
}
/// Decoded `CreateSubscriptionResponse`. Single field per
/// `AsbContracts.cs:225-230`.
#[derive(Debug, Clone, PartialEq)]
pub struct CreateSubscriptionResponse {
pub subscription_id: i64,
}
/// Decode a `CreateSubscriptionResponse` SOAP body. Looks for an inline
/// `<SubscriptionId>` text element under the wrapper.
pub fn decode_create_subscription_response(
body_tokens: &[NbfxToken],
dynamic: &mxaccess_asb_nettcp::nbfx::DynamicDictionary,
) -> Result<CreateSubscriptionResponse, OperationError> {
let id = find_inline_int64(body_tokens, "SubscriptionId", dynamic).ok_or(
OperationError::MissingField {
field: "SubscriptionId",
},
)?;
Ok(CreateSubscriptionResponse {
subscription_id: id,
})
}
/// Decoded `AddMonitoredItemsResponse`. `ItemCapabilities` is regular
/// WCF XML (not the binary fast-path) — currently surfaces as a presence
/// flag, mirroring `RegisterItemsResponse`.
#[derive(Debug, Clone, PartialEq)]
pub struct AddMonitoredItemsResponse {
pub status: Vec<ItemStatus>,
pub item_capabilities_present: bool,
}
pub fn decode_add_monitored_items_response(
body_tokens: &[NbfxToken],
) -> Result<AddMonitoredItemsResponse, OperationError> {
let payloads = collect_asbidata_payloads(body_tokens);
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(AddMonitoredItemsResponse {
status,
item_capabilities_present,
})
}
/// Decoded `PublishResponse`. Mirrors `AsbContracts.cs:294-304`:
/// `Status` (per-item operation status) + `Values` (one
/// `MonitoredItemValue` per delivered sample).
#[derive(Debug, Clone, PartialEq)]
pub struct PublishResponse {
pub status: Vec<ItemStatus>,
pub values: Vec<MonitoredItemValue>,
}
pub fn decode_publish_response(
body_tokens: &[NbfxToken],
) -> Result<PublishResponse, OperationError> {
let payloads = collect_asbidata_payloads(body_tokens);
let status_payload = payloads
.first()
.ok_or(OperationError::MissingField { field: "Status" })?;
let status = decode_item_status_array(status_payload)?;
let values = match payloads.get(1) {
Some(payload) => decode_monitored_item_value_array(payload)?,
None => Vec::new(),
};
Ok(PublishResponse { status, values })
}
/// Decoded `DeleteSubscriptionResponse`. Empty body per
/// `AsbContracts.cs:239-240` (`ConnectedResponse;` — no fields).
/// Always returns `Ok(())` regardless of body content; the decoder
/// exists for symmetry with the other operations.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DeleteSubscriptionResponse;
/// Walk `tokens` and find an inline int64 element-text under the named
/// element. Used for `<SubscriptionId>` and similar primitive response
/// fields. Permissive — skips attributes/xmlns decls between Element
/// and Text.
fn find_inline_int64(
tokens: &[NbfxToken],
name: &str,
dynamic: &mxaccess_asb_nettcp::nbfx::DynamicDictionary,
) -> Option<i64> {
let mut idx = 0;
while let Some(tok) = tokens.get(idx) {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == name {
let mut inner = idx + 1;
while matches!(
tokens.get(inner),
Some(NbfxToken::Attribute { .. })
| Some(NbfxToken::DefaultNamespace { .. })
| Some(NbfxToken::NamespaceDeclaration { .. })
) {
inner += 1;
}
match tokens.get(inner) {
Some(NbfxToken::Text(NbfxText::Int64(v))) => return Some(*v),
Some(NbfxToken::Text(NbfxText::Int32(v))) => return Some(*v as i64),
Some(NbfxToken::Text(NbfxText::Zero)) => return Some(0),
Some(NbfxToken::Text(NbfxText::One)) => return Some(1),
Some(NbfxToken::Text(text)) => {
// Fall back to text resolution + parse.
if let Some(s) = text.resolve(dynamic) {
if let Ok(v) = s.parse::<i64>() {
return Some(v);
}
}
return None;
}
_ => return None,
}
}
}
idx += 1;
}
None
}
/// 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 payloads = collect_asbidata_payloads(body_tokens);
let status_payload = payloads
.first()
.ok_or(OperationError::MissingField { field: "Status" })?;
let status = decode_item_status_array(status_payload)?;
let values = match payloads.get(1) {
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
/// 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);
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);
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
/// outer wrapper element. Returns `Vec<Vec<u8>>` ordered by
/// declaration position — for shapes with multiple binary fields
/// (e.g. `ReadResponse` has both `Status` and `Values`), the caller
/// indexes positionally.
///
/// `[F25 step 11 fix]` Previously this took a `field_name` parameter
/// and looked for `<{name}><ASBIData>{Bytes}</ASBIData></{name}>`.
/// .NET's `AsbDataCustomSerializer.WriteStartObject` actually
/// REPLACES the field's outer element with `<ASBIData>` directly
/// (`AsbContracts.cs:1561-1572`), so the wrapper element doesn't
/// exist on the wire — confirmed via `MxAsbClient.Probe
/// --dump-messages`. The function now returns all payloads in
/// declaration order; callers use `payloads[0]`, `payloads.get(1)`
/// etc.
pub fn collect_asbidata_payloads(tokens: &[NbfxToken]) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut idx = 0;
while let Some(tok) = tokens.get(idx) {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == "ASBIData" {
// Skip attributes / namespace decls between Element
// and Text.
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::Text(NbfxText::Bytes(payload))) = tokens.get(inner) {
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
/// <UnregisterItemsRequest xmlns="urn:msg.data.asb.iom:2">
/// <Items><ASBIData>{int32 count + each ItemIdentity binary}</ASBIData></Items>
/// </UnregisterItemsRequest>
/// ```
pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec<NbfxToken> {
let payload = encode_item_identity_array(items);
asbidata_request_body(
"UnregisterItemsRequest",
&[BodyField::asbidata("Items", payload)],
)
}
// ---- internal helpers ----------------------------------------------------
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
#[derive(Debug, Clone)]
#[allow(clippy::enum_variant_names, dead_code)] // every body field is in fact an element; suffix is descriptive. `name` on AsbiDataElement is retained for self-documentation but no longer emitted on the wire (see `asbidata_request_body`).
enum BodyField {
/// Plain element with text body.
BoolElement { name: &'static str, value: bool },
/// Plain element with int64 text body. WCF binary encoder emits
/// numeric values as Int8/16/32/64 records — we always pick Int64
/// for simplicity; the decoder accepts any width.
Int64Element { name: &'static str, value: i64 },
/// `<ASBIData>` element with binary content (NBFX `Bytes` record).
/// `name` is the .NET XmlElement attribute name (e.g. "Items",
/// "Values") — kept for self-documentation but ignored on the
/// wire because WCF's AsbDataCustomSerializer.WriteStartObject
/// replaces the field's outer element with `<ASBIData>` directly.
AsbiDataElement {
name: &'static str,
payload: Vec<u8>,
},
}
impl BodyField {
fn boolean(name: &'static str, value: bool) -> Self {
Self::BoolElement { name, value }
}
fn int64(name: &'static str, value: i64) -> Self {
Self::Int64Element { name, value }
}
/// `u64` is wider than `Int64Text`. WCF binary encodes large `ulong`s
/// as `UInt64Text` (record `0xB2`) which our F21 codec doesn't yet
/// emit; for the current proven value range (sample intervals,
/// queue sizes — all well under `i64::MAX`) we cast to `i64`. If a
/// future capture exposes values > `i64::MAX` we'll need to add
/// `UInt64` to `NbfxText`.
fn uint64(name: &'static str, value: u64) -> Self {
Self::Int64Element {
name,
value: value as i64,
}
}
fn asbidata(name: &'static str, payload: Vec<u8>) -> Self {
Self::AsbiDataElement { name, payload }
}
}
/// Emit `<{outer} xmlns="urn:msg.data.asb.iom:2"> ... </{outer}>` with
/// each [`BodyField`] in order.
fn asbidata_request_body(outer: &str, fields: &[BodyField]) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline(outer.to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(IOM_NS.to_string()),
},
];
for field in fields {
match field {
BodyField::BoolElement { name, value } => {
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline((*name).to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bool(*value)));
tokens.push(NbfxToken::EndElement);
}
BodyField::Int64Element { name, value } => {
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline((*name).to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Int64(*value)));
tokens.push(NbfxToken::EndElement);
}
BodyField::AsbiDataElement { name: _, payload } => {
// WCF's AsbDataCustomSerializer.WriteStartObject
// (`AsbContracts.cs:1561-1572`) REPLACES the field's
// outer element with `<ASBIData>` rather than nesting
// inside it. The `name` parameter (e.g. "Items",
// "Values") is ignored on the wire — the .NET
// XmlElement attribute name is overridden by the
// custom serializer. Verified via .NET probe
// `--dump-messages` output.
tokens.push(NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ASBIData".to_string()),
});
tokens.push(NbfxToken::Text(NbfxText::Bytes(payload.clone())));
tokens.push(NbfxToken::EndElement); // </ASBIData>
}
}
}
tokens.push(NbfxToken::EndElement); // </{outer}>
tokens
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use crate::contracts::decode_item_identity_array;
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
#[test]
fn register_items_body_round_trips_items_via_asbidata() {
let items = vec![
ItemIdentity::absolute_by_name("Tag.A"),
ItemIdentity::absolute_by_name("Tag.B"),
];
let body = build_register_items_request_body(&items, true, false);
// The body should open with <RegisterItemsRequest xmlns="...">
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "RegisterItemsRequest"
));
assert!(matches!(
&body[1],
NbfxToken::DefaultNamespace { value: NbfxText::Chars(ns) } if ns == IOM_NS
));
// Find the <ASBIData>{Bytes}</ASBIData> token sequence and pull
// the Bytes payload back out — it must round-trip the
// ItemIdentity array exactly.
let mut bytes_payload: Option<Vec<u8>> = None;
for window in body.windows(3) {
if matches!(
&window[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ASBIData"
) {
if let NbfxToken::Text(NbfxText::Bytes(b)) = &window[1] {
if matches!(window[2], NbfxToken::EndElement) {
bytes_payload = Some(b.clone());
break;
}
}
}
}
let payload = bytes_payload.expect("ASBIData Bytes record not found in body");
let decoded = decode_item_identity_array(&payload).unwrap();
assert_eq!(decoded, items);
}
#[test]
fn register_items_request_round_trips_through_envelope() {
// End-to-end: build_register_items_request_body → SoapEnvelope
// → encode_envelope → decode_envelope → re-extract body tokens
// → re-extract ItemIdentity array.
let items = vec![ItemIdentity::absolute_by_name("Tag.X")];
let body = build_register_items_request_body(&items, true, true);
let env = crate::SoapEnvelope::new(crate::actions::REGISTER_ITEMS).with_body_tokens(body);
let mut dyn_w = DynamicDictionary::new();
let bytes = crate::encode_envelope(&env, &mut dyn_w).unwrap();
let mut dyn_r = DynamicDictionary::new();
let decoded = crate::decode_envelope(&bytes, &mut dyn_r).unwrap();
assert_eq!(
decoded.action.as_deref(),
Some(crate::actions::REGISTER_ITEMS)
);
let mut bytes_payload: Option<Vec<u8>> = None;
for window in decoded.body_tokens.windows(3) {
if matches!(
&window[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ASBIData"
) {
if let NbfxToken::Text(NbfxText::Bytes(b)) = &window[1] {
bytes_payload = Some(b.clone());
break;
}
}
}
let payload = bytes_payload.expect("ASBIData payload missing from decoded envelope");
let recovered = decode_item_identity_array(&payload).unwrap();
assert_eq!(recovered, items);
}
#[test]
fn register_items_body_carries_require_id_and_register_only_booleans() {
let body = build_register_items_request_body(&[], true, false);
// After the <Items><ASBIData>{}</ASBIData></Items> sub-tree, the
// body should carry <RequireId>true</RequireId> followed by
// <RegisterOnly>false</RegisterOnly>. Because `Bytes(empty)`
// still emits a Bytes8 record + 1 EndElement + 1 EndElement,
// walk the tokens by name to be robust.
let mut saw_require_id_true = false;
let mut saw_register_only_false = false;
let mut idx = 0;
while idx < body.len() {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = &body[idx]
{
if local == "RequireId"
&& matches!(
body.get(idx + 1),
Some(NbfxToken::Text(NbfxText::Bool(true)))
)
{
saw_require_id_true = true;
}
if local == "RegisterOnly"
&& matches!(
body.get(idx + 1),
Some(NbfxToken::Text(NbfxText::Bool(false)))
)
{
saw_register_only_false = true;
}
}
idx += 1;
}
assert!(saw_require_id_true, "RequireId true not found");
assert!(saw_register_only_false, "RegisterOnly false not found");
}
#[test]
fn unregister_items_body_uses_correct_outer_element_name() {
let body = build_unregister_items_request_body(&[ItemIdentity::absolute_by_name("X")]);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "UnregisterItemsRequest"
));
// Should NOT have RequireId / RegisterOnly fields — the
// unregister contract has only the Items array.
for tok in &body {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
assert!(local != "RequireId");
assert!(local != "RegisterOnly");
}
}
}
#[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).is_empty());
}
#[test]
fn collect_asbidata_payloads_handles_multiple_fields_positionally() {
let body = asbidata_request_body(
"ReadResponse",
&[
BodyField::asbidata("Status", vec![1, 2, 3]),
BodyField::asbidata("Values", vec![4, 5, 6, 7]),
],
);
let payloads = collect_asbidata_payloads(&body);
assert_eq!(payloads, vec![vec![1u8, 2, 3], 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 connect_request_carries_connection_id_and_public_key() {
let cid = [0x12u8; 16];
let pubkey = vec![0xAB, 0xCD, 0xEF];
let body = build_connect_request_body(cid, &pubkey);
// Outer wrapper
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ConnectRequest"
));
// ConnectionId text contains hyphenated GUID form
let mut found_guid = false;
let mut found_pubkey_bytes = false;
for tok in &body {
if let NbfxToken::Text(NbfxText::Chars(s)) = tok {
if s.contains('-') && s.len() == 36 {
found_guid = true;
}
}
if let NbfxToken::Text(NbfxText::Bytes(b)) = tok {
if *b == pubkey {
found_pubkey_bytes = true;
}
}
}
assert!(found_guid, "ConnectionId text not found");
assert!(found_pubkey_bytes, "ConsumerPublicKey/Data bytes not found");
}
#[test]
fn disconnect_request_carries_data_and_iv_under_correct_wrapper() {
let data = vec![0xDEu8, 0xAD];
let iv = vec![0xBEu8, 0xEF];
let body = build_disconnect_request_body(&data, &iv);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "DisconnectRequest"
));
// Walk for the ConsumerAuthenticationData wrapper.
let mut saw_consumer_auth_data = false;
for tok in &body {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == "ConsumerAuthenticationData" {
saw_consumer_auth_data = true;
}
}
}
assert!(saw_consumer_auth_data);
let bytes_payloads: Vec<Vec<u8>> = body
.iter()
.filter_map(|tok| {
if let NbfxToken::Text(NbfxText::Bytes(b)) = tok {
Some(b.clone())
} else {
None
}
})
.collect();
assert_eq!(bytes_payloads, vec![data, iv]);
}
#[test]
fn authenticate_me_request_carries_data_and_iv() {
let data = vec![0x01, 0x02, 0x03];
let iv = vec![0x04, 0x05];
let body = build_authenticate_me_request_body(&data, &iv);
let bytes_payloads: Vec<Vec<u8>> = body
.iter()
.filter_map(|tok| {
if let NbfxToken::Text(NbfxText::Bytes(b)) = tok {
Some(b.clone())
} else {
None
}
})
.collect();
assert_eq!(bytes_payloads, vec![data, iv]);
}
#[test]
fn connect_response_round_trip() {
// Build a synthetic ConnectResponse body and decode it back.
let svc_pubkey = vec![0xFEu8, 0xED, 0xFA, 0xCE];
let svc_data = vec![0xBEu8, 0xEF];
let svc_iv = vec![0xCAu8, 0xFE];
let lifetime = "PT60M:V2".to_string();
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body: Vec<NbfxToken> = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectResponse".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(MESSAGES_NS.to_string()),
},
// ServicePublicKey
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ServicePublicKey".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(svc_pubkey.clone())),
NbfxToken::EndElement,
NbfxToken::EndElement,
// ServiceAuthenticationData
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ServiceAuthenticationData".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(svc_data.clone())),
NbfxToken::EndElement,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("InitializationVector".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(svc_iv.clone())),
NbfxToken::EndElement,
NbfxToken::EndElement,
// ConnectionLifetime
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectionLifetime".to_string()),
},
NbfxToken::Text(NbfxText::Chars(lifetime.clone())),
NbfxToken::EndElement,
// </ConnectResponse>
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let decoded = decode_connect_response(&body, &dict).unwrap();
assert_eq!(decoded.service_public_key, svc_pubkey);
assert_eq!(
decoded.service_authentication_data,
Some(AuthenticationDataBytes {
data: svc_data,
initialization_vector: svc_iv,
})
);
assert_eq!(decoded.connection_lifetime.as_deref(), Some("PT60M:V2"));
}
#[test]
fn connect_response_without_optional_fields() {
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body: Vec<NbfxToken> = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectResponse".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ServicePublicKey".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(vec![1, 2, 3])),
NbfxToken::EndElement,
NbfxToken::EndElement,
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let decoded = decode_connect_response(&body, &dict).unwrap();
assert_eq!(decoded.service_public_key, vec![1u8, 2, 3]);
assert!(decoded.service_authentication_data.is_none());
assert!(decoded.connection_lifetime.is_none());
}
#[test]
fn connect_response_missing_service_public_key_fails() {
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body: Vec<NbfxToken> = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectResponse".to_string()),
},
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let err = decode_connect_response(&body, &dict).unwrap_err();
assert!(matches!(
err,
OperationError::MissingField {
field: "ServicePublicKey/Data"
}
));
}
#[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 publish_write_complete_body_is_empty_wrapper() {
let body = build_publish_write_complete_request_body();
assert_eq!(body.len(), 3);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "PublishWriteCompleteRequest"
));
assert!(matches!(
&body[1],
NbfxToken::DefaultNamespace { value: NbfxText::Chars(ns) } if ns == IOM_NS
));
assert!(matches!(&body[2], NbfxToken::EndElement));
}
#[test]
fn publish_write_complete_response_counts_item_write_complete_elements() {
// Synthesize a body with two ItemWriteComplete elements.
let body = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("PublishWriteCompleteResponse".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("CompleteWrites".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ItemWriteComplete".to_string()),
},
NbfxToken::EndElement,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ItemWriteComplete".to_string()),
},
NbfxToken::EndElement,
NbfxToken::EndElement,
NbfxToken::EndElement,
];
let decoded = decode_publish_write_complete_response(&body).unwrap();
assert_eq!(decoded.complete_writes_count, 2);
}
#[test]
fn publish_write_complete_response_zero_when_no_callbacks() {
let body = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("PublishWriteCompleteResponse".to_string()),
},
NbfxToken::EndElement,
];
let decoded = decode_publish_write_complete_response(&body).unwrap();
assert_eq!(decoded.complete_writes_count, 0);
}
#[test]
fn delete_monitored_items_body_carries_subscription_id_and_items() {
let item = MinimalMonitoredItem::new(ItemIdentity::absolute_by_name("Tag.A"), 1000);
let body = build_delete_monitored_items_request_body(11, &[item]);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "DeleteMonitoredItemsRequest"
));
let mut saw_id = false;
let mut saw_monitored_item = false;
for tok in &body {
if let NbfxToken::Text(NbfxText::Int64(11)) = tok {
saw_id = true;
}
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == "MonitoredItem" {
saw_monitored_item = true;
}
}
}
assert!(saw_id);
assert!(saw_monitored_item);
}
#[test]
fn delete_monitored_items_body_omits_require_id_field() {
let item = MinimalMonitoredItem::new(ItemIdentity::absolute_by_name("Tag.A"), 1000);
let body = build_delete_monitored_items_request_body(7, &[item]);
// The DeleteMonitoredItems contract has no RequireId field;
// assert it doesn't show up.
for tok in &body {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
assert!(local != "RequireId");
}
}
}
#[test]
fn delete_monitored_items_response_round_trip() {
use mxaccess_codec::AsbStatus;
let status = vec![ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.D"),
status: AsbStatus::default(),
error_code: 0,
error_code_specified: true,
}];
let payload = crate::contracts::encode_item_status_array(&status);
let body = asbidata_request_body(
"DeleteMonitoredItemsResponse",
&[BodyField::asbidata("Status", payload)],
);
let decoded = decode_delete_monitored_items_response(&body).unwrap();
assert_eq!(decoded.status, status);
}
#[test]
fn write_request_body_carries_items_values_and_write_handle() {
use mxaccess_codec::AsbVariant;
let items = vec![ItemIdentity::absolute_by_name("Tag.X")];
let values = vec![MinimalWriteValue::new(AsbVariant::from_i32(42))];
let body = build_write_request_body(&items, &values, 7);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "WriteBasicRequest"
));
// WriteHandle = 7 (Int32)
let mut saw_write_handle = false;
let mut saw_write_value_element = false;
for tok in &body {
if let NbfxToken::Text(NbfxText::Int32(7)) = tok {
saw_write_handle = true;
}
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == "WriteValue" {
saw_write_value_element = true;
}
}
}
assert!(saw_write_handle);
assert!(saw_write_value_element);
}
#[test]
fn write_request_body_pairs_items_and_values_arrays() {
use mxaccess_codec::AsbVariant;
let items = vec![
ItemIdentity::absolute_by_name("Tag.A"),
ItemIdentity::absolute_by_name("Tag.B"),
];
let values = vec![
MinimalWriteValue::new(AsbVariant::from_i32(1)),
MinimalWriteValue::new(AsbVariant::from_i32(2)),
];
let body = build_write_request_body(&items, &values, 0);
// Two WriteValue elements should appear under <Values>.
let n_write_value_elements = body
.iter()
.filter(|tok| {
matches!(
tok,
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "WriteValue"
)
})
.count();
assert_eq!(n_write_value_elements, 2);
}
#[test]
fn write_response_round_trips_status_array() {
use mxaccess_codec::AsbStatus;
let status = vec![ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.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("WriteResponse", &[BodyField::asbidata("Status", payload)]);
let decoded = decode_write_response(&body).unwrap();
assert_eq!(decoded.status, status);
}
#[test]
fn write_response_missing_status_fails() {
let body = asbidata_request_body("WriteResponse", &[]);
let err = decode_write_response(&body).unwrap_err();
assert!(matches!(
err,
OperationError::MissingField { field: "Status" }
));
}
#[test]
fn create_subscription_body_carries_max_queue_and_sample_interval() {
let body = build_create_subscription_request_body(0, 1000);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "CreateSubscriptionRequest"
));
let int_payloads: Vec<i64> = body
.iter()
.filter_map(|tok| {
if let NbfxToken::Text(NbfxText::Int64(v)) = tok {
Some(*v)
} else {
None
}
})
.collect();
assert_eq!(int_payloads, vec![0, 1000]);
}
#[test]
fn create_subscription_response_decodes_int64_subscription_id() {
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("CreateSubscriptionResponse".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("SubscriptionId".to_string()),
},
NbfxToken::Text(NbfxText::Int64(42)),
NbfxToken::EndElement,
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let decoded = decode_create_subscription_response(&body, &dict).unwrap();
assert_eq!(decoded.subscription_id, 42);
}
#[test]
fn create_subscription_response_decodes_chars_subscription_id() {
// WCF can also emit numerics as text-Chars rather than Int64Text.
// Verify the decoder's parse-fallback path handles that.
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("CreateSubscriptionResponse".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("SubscriptionId".to_string()),
},
NbfxToken::Text(NbfxText::Chars("12345".to_string())),
NbfxToken::EndElement,
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let decoded = decode_create_subscription_response(&body, &dict).unwrap();
assert_eq!(decoded.subscription_id, 12345);
}
#[test]
fn create_subscription_response_missing_id_fails() {
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("CreateSubscriptionResponse".to_string()),
},
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let err = decode_create_subscription_response(&body, &dict).unwrap_err();
assert!(matches!(
err,
OperationError::MissingField {
field: "SubscriptionId"
}
));
}
#[test]
fn add_monitored_items_body_includes_subscription_id_and_items() {
let item = MinimalMonitoredItem::new(ItemIdentity::absolute_by_name("Tag.A"), 1000);
let body = build_add_monitored_items_request_body(7, &[item], true);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "AddMonitoredItemsRequest"
));
// Find SubscriptionId text
let mut saw_id_7 = false;
let mut saw_monitored_item = false;
for tok in &body {
if let NbfxToken::Text(NbfxText::Int64(7)) = tok {
saw_id_7 = true;
}
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == "MonitoredItem" {
saw_monitored_item = true;
}
}
}
assert!(saw_id_7);
assert!(saw_monitored_item);
}
#[test]
fn delete_subscription_body_carries_subscription_id() {
let body = build_delete_subscription_request_body(99);
let int_payloads: Vec<i64> = body
.iter()
.filter_map(|tok| {
if let NbfxToken::Text(NbfxText::Int64(v)) = tok {
Some(*v)
} else {
None
}
})
.collect();
assert_eq!(int_payloads, vec![99]);
}
#[test]
fn publish_body_carries_subscription_id() {
let body = build_publish_request_body(123);
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "PublishRequest"
));
let int_payloads: Vec<i64> = body
.iter()
.filter_map(|tok| {
if let NbfxToken::Text(NbfxText::Int64(v)) = tok {
Some(*v)
} else {
None
}
})
.collect();
assert_eq!(int_payloads, vec![123]);
}
#[test]
fn publish_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![MonitoredItemValue {
item: ItemIdentity::absolute_by_name("Tag.A"),
value: RuntimeValue {
timestamp_binary: 555,
timestamp_specified: true,
value: AsbVariant::from_i32(7),
status: AsbStatus::default(),
},
user_data: AsbVariant::empty(),
}];
let status_payload = crate::contracts::encode_item_status_array(&status);
let values_payload = crate::contracts::encode_monitored_item_value_array(&values);
let body = asbidata_request_body(
"PublishResponse",
&[
BodyField::asbidata("Status", status_payload),
BodyField::asbidata("Values", values_payload),
],
);
let decoded = decode_publish_response(&body).unwrap();
assert_eq!(decoded.status, status);
assert_eq!(decoded.values, values);
}
#[test]
fn add_monitored_items_response_round_trip() {
use mxaccess_codec::AsbStatus;
let status = vec![ItemStatus {
item: ItemIdentity::absolute_by_name("Tag.M"),
status: AsbStatus::default(),
error_code: 0,
error_code_specified: true,
}];
let payload = crate::contracts::encode_item_status_array(&status);
let body = asbidata_request_body(
"AddMonitoredItemsResponse",
&[BodyField::asbidata("Status", payload)],
);
let decoded = decode_add_monitored_items_response(&body).unwrap();
assert_eq!(decoded.status, status);
assert!(!decoded.item_capabilities_present);
}
#[test]
fn empty_items_array_still_produces_valid_envelope() {
let body = build_register_items_request_body(&[], false, false);
let env = crate::SoapEnvelope::new(crate::actions::REGISTER_ITEMS).with_body_tokens(body);
let mut dyn_w = DynamicDictionary::new();
let bytes = crate::encode_envelope(&env, &mut dyn_w).unwrap();
// Round-trip — at minimum, the action must come back.
let mut dyn_r = DynamicDictionary::new();
let decoded = crate::decode_envelope(&bytes, &mut dyn_r).unwrap();
assert_eq!(
decoded.action.as_deref(),
Some(crate::actions::REGISTER_ITEMS)
);
}
}