From 101a8b13f57bf1571df9842aa31ba505e4162c15 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 6 May 2026 04:01:11 -0400 Subject: [PATCH] [F34] mxaccess-asb: AddMonitoredItems body uses DataContract field names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite push_monitored_item_body to emit the DataContract field-suffix names from AsbContracts.cs:940-965 (activeField, bufferedField, itemField, sampleIntervalField, timeDeadbandField, userDataField, valueDeadbandField) under prefix `b` bound to the http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract namespace. The wrapper now declares xmlns:b + xmlns:i. The legacy XmlSerializer property names (, , , ) only matter for the canonical-XML HMAC signing input — that emitter at xml_canonical::emit_monitored_item is unchanged and F28 fixture byte-equality still holds for all 13 ops. On the binary NBFX wire MxDataProvider's DataContractSerializer expects the field-suffix form. Wire-byte type encoding matches the captured fixture (add-monitored-items-request-wire.bin): bool → Bool record, ulong → Zero/One/Chars (XmlConvert decimal text), ushort → Zero/One/Int8/Int16/Int32 (smallest-fit binary). Empty string? + null byte[]? emit as empty elements with no attribute (matching the wire). Field order follows the explicit [DataMember(Order = N)] sequence. Adjacent: ItemIdentity is nested via DataContract field names too — NOT the binary fast-path, which only kicks in at top-level message body members. Verified live against AVEVA MxDataProvider: AddMonitoredItems now returns 1 status item with error_code=0x0000 (previously 0 items; the silent failure was the deliberate DC-schema mismatch); Publish poll #4 delivers the actual tag value as AsbVariant { type_id: 4, length: 4, payload: [99,0,0,0] } through the F26 stream. Pre-existing clippy::format_collect errors in auth.rs:339,342 and client.rs:952 fixed in passing — they were blocking workspace clippy otherwise. Workspace: 757 → 758 tests, clippy -D warnings clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 10 +- rust/crates/mxaccess-asb-nettcp/src/auth.rs | 11 +- rust/crates/mxaccess-asb/src/client.rs | 6 +- rust/crates/mxaccess-asb/src/operations.rs | 381 ++++++++++++++++++-- 4 files changed, 362 insertions(+), 46 deletions(-) diff --git a/design/followups.md b/design/followups.md index ddc923d..4081daf 100644 --- a/design/followups.md +++ b/design/followups.md @@ -75,14 +75,18 @@ move to `## Resolved` with a date + commit hash. For the per-step body of every line listed in the cumulative execution log, see the matching commit message — each commit is a single F-number step with its own scope, fixtures, test count delta, and follow-up notes. The detailed per-step write-ups previously inlined here added little beyond what `git show ` provides. ### F34 — `MonitoredItem` wire format: DataContract field-suffix names, not XmlSerializer property names -**Severity:** P2 — only affects the F26 stream's data flow against MxDataProvider; canonical-XML HMAC signing for the operation is verified working (server accepts the request, returns a non-fault response). +**Status:** Resolved 2026-05-06 — `push_monitored_item_body` now emits the `[DataMember(Name = "...Field")]` private-field names from `AsbContracts.cs:940-965` under prefix `b` bound to the DC namespace. Live `cargo run -p mxaccess --example asb-subscribe` against the AVEVA install confirms `AddMonitoredItems` returns 1 status item with `error_code=0x0000`, and a subsequent `Publish` poll delivers the actual tag value (`AsbVariant { type_id: 4, length: 4, payload: [99, 0, 0, 0] }`). Both halves of F34 are now closed (response decoder + request body emitter). **Severity:** P2 — only affects the F26 stream's data flow against MxDataProvider; canonical-XML HMAC signing for the operation is verified working (server accepts the request, returns a non-fault response). **Source:** Live `cargo run -p mxaccess --example asb-subscribe` + `examples/asb-relay.rs` capture, 2026-05-06. -**Two sub-issues, one closed and one open.** +**Two sub-issues, both closed.** **Closed: `decode_publish_response` filtered empty `` placeholders out of the positional payload list.** Captured the full S→C bytes of a working `PublishResponse` via `examples/asb-relay.rs` between the .NET probe and MxDataProvider (fixture stashed at `crates/mxaccess-asb/tests/fixtures/publish-response-with-value.bin`). The wire shape is `{bytes}` — Status is empty-but-present, Values carries the binary `MonitoredItemValue[]`. Our `collect_asbidata_payloads` previously skipped the empty Status, shifting Values down to index `0` where the decoder mis-read it as Status and corrupted the parse. Fix: always push every `` element as a positional entry, empty or not. `tests/publish_capture.rs` runs the full decode chain over the real wire bytes and asserts `values.len() == 1`. **Verified 2026-05-06.** -**Open: AddMonitoredItems / DeleteMonitoredItems request bodies use the wrong element-name form on the binary NBFX wire.** Live capture of the .NET probe's `AddMonitoredItems` request exposes a per-session NBFX dictionary declaring these strings *(verbatim, in declaration order)*: `activeField`, `activeFieldSpecified`, `bufferedField`, `itemField`, `contextNameField`, `idField`, `idFieldSpecified`, `nameField`, `referenceTypeField`, `typeField`, `sampleIntervalField`, `timeDeadbandField`, `timeDeadbandFieldSpecified`, `userDataField`, `lengthField`, `payloadField`, `valueDeadbandField`. These are the `[DataMember(Name = "...")]` private-field names from `AsbContracts.cs:940-965` — the wire form chosen by `DataContractSerializer` for non-`IAsbCustomSerializableType` types like `MonitoredItem`. Our `build_add_monitored_items_request_body` (and `build_delete_monitored_items_request_body`) emits XmlSerializer-property names like ``, ``, ``, `` — those are the *canonical XML for HMAC* shape (XmlSerializer-derived), which is correct for the signing input but wrong for the binary wire payload. MxDataProvider silently fails to register monitored items whose field names don't match its DataContract schema, returns a 0-length `Status` array (`successField=true`, `resultCode=0`, but no items actually registered), and consequently the `Publish` poll loop forever returns empty `Values`. +**Closed: AddMonitoredItems / DeleteMonitoredItems request bodies now emit DataContract field-suffix names.** Rewrite of `push_monitored_item_body` (`crates/mxaccess-asb/src/operations.rs`) replaces the legacy XmlSerializer property names (``, ``, ``, ``, ``) with the WCF DataContract field-suffix names (``, ``, ``, ``, `` (with nested ItemIdentity DC fields), ``, ``, ``, `` (Variant), `` (Variant)) emitted under prefix `b` bound to `http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract`. The `` wrapper now declares `xmlns:b` + `xmlns:i` (XSI). Wire-byte choices match the captured fixture: `bool` → Bool record, `ulong` → Zero/One/Chars (decimal text via XmlConvert), `ushort` → Zero/One/Int8/Int16/Int32 (smallest-fit binary), `int32` → same. Empty `string?` and null `byte[]?` emit as empty elements (no `` attribute, matching the wire). Field order follows the `[DataMember(Order = N)]` declarations explicitly. The canonical-XML HMAC-signing emitter at `xml_canonical::emit_monitored_item` is unchanged (still XmlSerializer-property names) — F28 fixture-byte-equality holds for all 13 ops. Verified via: +1. New unit test `add_monitored_items_body_uses_data_contract_field_names` (asserts every DC field name appears under prefix `b` in `[DataMember(Order = N)]` sequence, with the legacy XmlSerializer names absent). +2. Live `cargo run -p mxaccess --example asb-subscribe` against AVEVA: `AddMonitoredItems` now returns 1 status item with `error_code=0x0000` (was 0 items previously); `Publish` poll #4 delivers the tag value `99` over the wire. Workspace `cargo test` 757 → 758 pass; clippy clean. + +**Original observation that drove the fix:** Live capture of the .NET probe's `AddMonitoredItems` request exposes a per-session NBFX dictionary declaring these strings *(verbatim, in declaration order)*: `activeField`, `activeFieldSpecified`, `bufferedField`, `itemField`, `contextNameField`, `idField`, `idFieldSpecified`, `nameField`, `referenceTypeField`, `typeField`, `sampleIntervalField`, `timeDeadbandField`, `timeDeadbandFieldSpecified`, `userDataField`, `lengthField`, `payloadField`, `valueDeadbandField`. These are the `[DataMember(Name = "...")]` private-field names from `AsbContracts.cs:940-965` — the wire form chosen by `DataContractSerializer` for non-`IAsbCustomSerializableType` types like `MonitoredItem`. Our `build_add_monitored_items_request_body` (and `build_delete_monitored_items_request_body`) emits XmlSerializer-property names like ``, ``, ``, `` — those are the *canonical XML for HMAC* shape (XmlSerializer-derived), which is correct for the signing input but wrong for the binary wire payload. MxDataProvider silently fails to register monitored items whose field names don't match its DataContract schema, returns a 0-length `Status` array (`successField=true`, `resultCode=0`, but no items actually registered), and consequently the `Publish` poll loop forever returns empty `Values`. **The dual-format world**: ASB requests have *two* element-name conventions on the wire: - **HMAC canonical XML** (input to `AsbAuthenticator::Sign`): XmlSerializer-derived names — ``, ``, `` etc. Driven by `[XmlElement(...)]` and property names. Our `xml_canonical` emitter is byte-equal to .NET here (F28 step 2 fixtures verify). diff --git a/rust/crates/mxaccess-asb-nettcp/src/auth.rs b/rust/crates/mxaccess-asb-nettcp/src/auth.rs index c671db0..c55e875 100644 --- a/rust/crates/mxaccess-asb-nettcp/src/auth.rs +++ b/rust/crates/mxaccess-asb-nettcp/src/auth.rs @@ -335,11 +335,18 @@ impl AsbAuthenticator { out.as_mut_slice(), ); if std::env::var("MX_ASB_TRACE_DERIVE").ok().is_some() { + use std::fmt::Write as _; eprintln!("asb.derive.crypto_key.len={}", crypto_key.len()); - let hex: String = crypto_key.iter().map(|b| format!("{b:02X}")).collect(); + let hex = crypto_key.iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02X}"); + s + }); eprintln!("asb.derive.crypto_key.hex={hex}"); eprintln!("asb.derive.crypto_key.b64={password_b64}"); - let aes_hex: String = out.iter().map(|b| format!("{b:02X}")).collect(); + let aes_hex = out.iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02X}"); + s + }); eprintln!("asb.derive.aes_key.hex={aes_hex}"); } Ok(out) diff --git a/rust/crates/mxaccess-asb/src/client.rs b/rust/crates/mxaccess-asb/src/client.rs index 4acc863..9e26c7e 100644 --- a/rust/crates/mxaccess-asb/src/client.rs +++ b/rust/crates/mxaccess-asb/src/client.rs @@ -947,9 +947,13 @@ fn detect_soap_fault(decoded: &crate::DecodedEnvelope) -> Option { /// Hex dump for diagnostic traces. First 256 bytes only to keep /// MX_ASB_TRACE_REPLY output bounded. fn hex_dump(bytes: &[u8]) -> String { + use std::fmt::Write as _; let cap = bytes.len().min(256); let slice = bytes.get(..cap).unwrap_or(&[]); - slice.iter().map(|b| format!("{b:02x}")).collect() + slice.iter().fold(String::with_capacity(cap * 2), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) } // ---- error type ---------------------------------------------------------- diff --git a/rust/crates/mxaccess-asb/src/operations.rs b/rust/crates/mxaccess-asb/src/operations.rs index 1591107..b170e34 100644 --- a/rust/crates/mxaccess-asb/src/operations.rs +++ b/rust/crates/mxaccess-asb/src/operations.rs @@ -517,6 +517,14 @@ pub fn build_delete_monitored_items_request_body( prefix: None, name: NbfxName::Inline("Items".to_string()), }, + NbfxToken::NamespaceDeclaration { + prefix: "b".to_string(), + value: NbfxText::Chars(DC_ASBIDATAV2_NS.to_string()), + }, + NbfxToken::NamespaceDeclaration { + prefix: "i".to_string(), + value: NbfxText::Chars(XSI_NS.to_string()), + }, ]; for item in items { push_monitored_item_body(&mut tokens, item); @@ -767,11 +775,19 @@ pub fn build_add_monitored_items_request_body( }, NbfxToken::Text(NbfxText::Int64(subscription_id)), NbfxToken::EndElement, - // + // NbfxToken::Element { prefix: None, name: NbfxName::Inline("Items".to_string()), }, + NbfxToken::NamespaceDeclaration { + prefix: "b".to_string(), + value: NbfxText::Chars(DC_ASBIDATAV2_NS.to_string()), + }, + NbfxToken::NamespaceDeclaration { + prefix: "i".to_string(), + value: NbfxText::Chars(XSI_NS.to_string()), + }, ]; for item in items { push_monitored_item_body(&mut tokens, item); @@ -788,55 +804,185 @@ pub fn build_add_monitored_items_request_body( tokens } -/// Emit a single `...` NBFX subtree. +/// Emit a single `...` NBFX subtree. /// Shared between AddMonitoredItems and DeleteMonitoredItems request -/// builders. Field order matches the .NET `MonitoredItem` declaration: -/// Item / SampleInterval / Active (when Specified) / Buffered. +/// builders. +/// +/// **Wire shape: DataContract field-suffix names, NOT XmlSerializer +/// property names.** MxDataProvider's binary deserialiser is the +/// `DataContractSerializer`-driven path for non-`IAsbCustomSerializable` +/// types like `MonitoredItem`, so the on-the-wire element names are the +/// `[DataMember(Name = "...")]` private-field names from +/// `AsbContracts.cs:940-965` — `activeField`, `bufferedField`, +/// `itemField`, `sampleIntervalField`, etc. — and they live in the DC +/// namespace `http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract` +/// (prefix `b`). +/// +/// Field order follows the explicit `[DataMember(Order = N)]` attributes +/// (alphabetical-by-default for DC, but explicitly numbered here): +/// `activeField`, `activeFieldSpecified`, `bufferedField`, `itemField`, +/// `sampleIntervalField`, `timeDeadbandField`, +/// `timeDeadbandFieldSpecified`, `userDataField`, `valueDeadbandField`. +/// +/// The canonical-XML HMAC signing path (`xml_canonical::emit_monitored_item`) +/// uses XmlSerializer property names (``, ``, etc.) — that +/// stays unchanged because `XmlSerializer.Serialize` is what the .NET +/// `AsbSystemAuthenticator.Sign` HMACs over (canonical XML form). +/// Verified against the captured `add-monitored-items-request-wire.bin` +/// fixture — F34. fn push_monitored_item_body(tokens: &mut Vec, item: &MinimalMonitoredItem) { tokens.push(NbfxToken::Element { - prefix: None, + prefix: Some("b".to_string()), name: NbfxName::Inline("MonitoredItem".to_string()), }); - // {ItemIdentity binary} + // Order 0: activeField (bool — defaults to false when not Specified) + push_b_bool(tokens, "activeField", item.active.unwrap_or(false)); + // Order 1: activeFieldSpecified (bool — true iff `active` is Some) + push_b_bool(tokens, "activeFieldSpecified", item.active.is_some()); + // Order 2: bufferedField + push_b_bool(tokens, "bufferedField", item.buffered); + // Order 3: itemField (nested ItemIdentity, DataContract-serialised + // — NOT the binary fast-path, which only kicks in at + // top-level message body members). + push_b_item_identity(tokens, &item.item); + // Order 4: sampleIntervalField (ulong — WCF binary writer emits + // ulong as `Chars8`/etc. text via `XmlConvert.ToString` for non-0/1 + // values; 0/1 collapse to the Zero/One text records). + push_b_ulong_text(tokens, "sampleIntervalField", item.sample_interval); + // Order 5+6: timeDeadbandField + timeDeadbandFieldSpecified — + // omitted-from-public-API on `MinimalMonitoredItem`; emit defaults. + push_b_ulong_text(tokens, "timeDeadbandField", 0); + push_b_bool(tokens, "timeDeadbandFieldSpecified", false); + // Order 7: userDataField (empty Variant — typeField=65535 = "no value") + push_b_empty_variant(tokens, "userDataField"); + // Order 8: valueDeadbandField (empty Variant) + push_b_empty_variant(tokens, "valueDeadbandField"); + tokens.push(NbfxToken::EndElement); // +} + +/// `{bool}` — Bool text record (with-end-element +/// variant chosen by the encoder). +fn push_b_bool(tokens: &mut Vec, name: &str, value: bool) { tokens.push(NbfxToken::Element { - prefix: None, - name: NbfxName::Inline("Item".to_string()), + prefix: Some("b".to_string()), + name: NbfxName::Inline(name.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); // - tokens.push(NbfxToken::EndElement); // - // - 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::Text(NbfxText::Bool(value))); tokens.push(NbfxToken::EndElement); - // — emitted only when ActiveSpecified=true - // (`MonitoredItem.Active` setter at `AsbContracts.cs:982-987`). - // Required to make MxDataProvider actually deliver values; F34. - if let Some(active) = item.active { - tokens.push(NbfxToken::Element { - prefix: None, - name: NbfxName::Inline("Active".to_string()), - }); - tokens.push(NbfxToken::Text(NbfxText::Bool(active))); - tokens.push(NbfxToken::EndElement); +} + +/// `{ulong-as-text}` — WCF emits `ulong` via +/// `XmlConvert.ToString` (decimal text) which the binary writer then +/// encodes as `Chars8`. Values 0 and 1 collapse to the dedicated +/// `ZeroText` / `OneText` records that the WCF binary writer prefers +/// when the text would be `"0"` / `"1"`. +fn push_b_ulong_text(tokens: &mut Vec, name: &str, value: u64) { + tokens.push(NbfxToken::Element { + prefix: Some("b".to_string()), + name: NbfxName::Inline(name.to_string()), + }); + let text = match value { + 0 => NbfxText::Zero, + 1 => NbfxText::One, + n => NbfxText::Chars(n.to_string()), + }; + tokens.push(NbfxToken::Text(text)); + tokens.push(NbfxToken::EndElement); +} + +/// `{ushort-as-binary}` — `ushort` goes through +/// `WriteInt32` in WCF binary, which emits `Zero` / `One` for those +/// values and `Int8` / `Int16` / `Int32` for larger values (smallest +/// width that fits). +fn push_b_ushort(tokens: &mut Vec, name: &str, value: u16) { + tokens.push(NbfxToken::Element { + prefix: Some("b".to_string()), + name: NbfxName::Inline(name.to_string()), + }); + let text = match value { + 0 => NbfxText::Zero, + 1 => NbfxText::One, + n if n <= i8::MAX as u16 => NbfxText::Int8(n as i8), + n if n <= i16::MAX as u16 => NbfxText::Int16(n as i16), + n => NbfxText::Int32(n as i32), + }; + tokens.push(NbfxToken::Text(text)); + tokens.push(NbfxToken::EndElement); +} + +/// `{string-or-empty-element}` — WCF emits a +/// non-empty string as `Chars8/16/32` text and `Some("")` / `None` as +/// an empty element (no child text). The captured wire shows no +/// `i:nil="true"` attribute even when the field semantically maps to +/// .NET `null`, so we skip the nil-attribute path. +fn push_b_string(tokens: &mut Vec, name: &str, value: Option<&str>) { + tokens.push(NbfxToken::Element { + prefix: Some("b".to_string()), + name: NbfxName::Inline(name.to_string()), + }); + if let Some(s) = value { + if !s.is_empty() { + tokens.push(NbfxToken::Text(NbfxText::Chars(s.to_string()))); + } } - // - 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); // +} + +/// Emit a nested `ItemIdentity` as DataContract fields. Order matches +/// `AsbContracts.cs:533-553`: contextNameField, idField, idFieldSpecified, +/// nameField, referenceTypeField, typeField (alphabetical by member +/// name = the explicit `[DataMember(Order = N)]` ordering). +fn push_b_item_identity(tokens: &mut Vec, identity: &ItemIdentity) { + tokens.push(NbfxToken::Element { + prefix: Some("b".to_string()), + name: NbfxName::Inline("itemField".to_string()), + }); + push_b_string(tokens, "contextNameField", identity.context_name.as_deref()); + push_b_ulong_text(tokens, "idField", identity.id); + push_b_bool(tokens, "idFieldSpecified", identity.id_specified); + push_b_string(tokens, "nameField", identity.name.as_deref()); + push_b_ushort(tokens, "referenceTypeField", identity.reference_type); + push_b_ushort(tokens, "typeField", identity.kind); + tokens.push(NbfxToken::EndElement); // +} + +/// Emit an empty `Variant` (no payload, type = 65535 = "no value"). +/// Field order follows `AsbContracts.cs:1170-1181`: lengthField, +/// payloadField, typeField. +fn push_b_empty_variant(tokens: &mut Vec, name: &str) { + tokens.push(NbfxToken::Element { + prefix: Some("b".to_string()), + name: NbfxName::Inline(name.to_string()), + }); + push_b_int_text(tokens, "lengthField", 0); + // payloadField is `byte[]?`; an empty/null value emits as an empty + // element (no `` attribute on the captured wire). + tokens.push(NbfxToken::Element { + prefix: Some("b".to_string()), + name: NbfxName::Inline("payloadField".to_string()), + }); + tokens.push(NbfxToken::EndElement); + push_b_ushort(tokens, "typeField", 65535); + tokens.push(NbfxToken::EndElement); // +} + +/// `{int32}` — int32 via the smallest-fit binary +/// text record (matches WCF's `WriteInt32` which collapses 0 / 1 to +/// the Zero / One text records). +fn push_b_int_text(tokens: &mut Vec, name: &str, value: i32) { + tokens.push(NbfxToken::Element { + prefix: Some("b".to_string()), + name: NbfxName::Inline(name.to_string()), + }); + let text = match value { + 0 => NbfxText::Zero, + 1 => NbfxText::One, + n if (i8::MIN as i32..=i8::MAX as i32).contains(&n) => NbfxText::Int8(n as i8), + n if (i16::MIN as i32..=i16::MAX as i32).contains(&n) => NbfxText::Int16(n as i16), + n => NbfxText::Int32(n), + }; + tokens.push(NbfxToken::Text(text)); + tokens.push(NbfxToken::EndElement); } /// Minimal `MonitoredItem` shape covering `Item`, `SampleInterval`, @@ -1457,6 +1603,19 @@ pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec` / `` payloads. +const DC_ASBIDATAV2_NS: &str = + "http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract"; + +/// `xsi` namespace, declared (often unused) on the `` wrapper +/// alongside the DC namespace. WCF declares it preemptively because +/// any nullable DataContract field could emit `i:nil="true"`. +const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance"; + #[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 { @@ -2570,6 +2729,148 @@ mod tests { assert!(saw_monitored_item); } + /// F34 — verify the rewritten `push_monitored_item_body` emits the + /// DataContract field-suffix names under the `b` prefix that + /// MxDataProvider's binary deserialiser actually expects, in the + /// `[DataMember(Order = N)]` order from `AsbContracts.cs:940-965`. + /// Captured wire `tests/fixtures/add-monitored-items-request-wire.bin` + /// is the source of truth. + #[test] + fn add_monitored_items_body_uses_data_contract_field_names() { + let item = MinimalMonitoredItem::with_active( + ItemIdentity::absolute_by_name("TestChildObject.TestInt"), + 1000, + true, + ); + let body = build_add_monitored_items_request_body(11, &[item], true); + + // Collect every (prefix, name) for `Element` tokens. The new + // builder emits each `MonitoredItem` child under prefix `b` + // with the `[DataMember(Name = "...")]` field-suffix name. + let elements: Vec<(Option<&str>, &str)> = body + .iter() + .filter_map(|tok| { + if let NbfxToken::Element { + prefix, + name: NbfxName::Inline(local), + } = tok + { + Some((prefix.as_deref(), local.as_str())) + } else { + None + } + }) + .collect(); + + // The MonitoredItem itself uses prefix `b`. + assert!( + elements.contains(&(Some("b"), "MonitoredItem")), + "expected , got {elements:?}" + ); + + // All 9 DataContract field names appear under prefix `b`, in + // declaration order. + let expected_dc_fields = [ + "activeField", + "activeFieldSpecified", + "bufferedField", + "itemField", + "sampleIntervalField", + "timeDeadbandField", + "timeDeadbandFieldSpecified", + "userDataField", + "valueDeadbandField", + ]; + let dc_field_positions: Vec = expected_dc_fields + .iter() + .map(|f| { + elements + .iter() + .position(|(p, n)| *p == Some("b") && n == f) + .unwrap_or_else(|| panic!("missing in body")) + }) + .collect(); + // Strictly increasing → fields appear in DC Order(N) sequence. + for window in dc_field_positions.windows(2) { + assert!( + window[0] < window[1], + "DC fields out of order: {expected_dc_fields:?} → {dc_field_positions:?}" + ); + } + + // ItemIdentity sub-fields appear under prefix `b` (nested + // DataContract serialisation, NOT the binary + // fast-path which only kicks in at top-level body members). + for ii_field in [ + "contextNameField", + "idField", + "idFieldSpecified", + "nameField", + "referenceTypeField", + "typeField", + ] { + assert!( + elements.contains(&(Some("b"), ii_field)), + "expected nested from ItemIdentity, got {elements:?}" + ); + } + + // Variant sub-fields (lengthField/payloadField/typeField) + // appear for both userDataField and valueDeadbandField. + let length_count = elements + .iter() + .filter(|(p, n)| *p == Some("b") && *n == "lengthField") + .count(); + let payload_count = elements + .iter() + .filter(|(p, n)| *p == Some("b") && *n == "payloadField") + .count(); + assert_eq!( + length_count, 2, + "expected 2x (userData + valueDeadband Variants)" + ); + assert_eq!( + payload_count, 2, + "expected 2x (userData + valueDeadband Variants)" + ); + + // The legacy XmlSerializer property names (Active / Item / + // SampleInterval / Buffered) MUST NOT appear on the wire — the + // canonical-XML signing path uses those names, but the binary + // body uses the DataContract suffix names exclusively. Asserts + // the legacy NBFX-bytes shape is fully retired for this op. + for legacy in ["Active", "Buffered", "SampleInterval", "ASBIData"] { + assert!( + !elements.iter().any(|(_, n)| *n == legacy), + "legacy XmlSerializer name <{legacy}> should not appear in DC body" + ); + } + + // The wrapper declares `xmlns:b` (DC namespace) and + // `xmlns:i` (XSI). Verified by scanning for NamespaceDeclaration + // tokens immediately following the `` open. + let xmlns_decls: Vec<(&str, &NbfxText)> = body + .iter() + .filter_map(|tok| { + if let NbfxToken::NamespaceDeclaration { prefix, value } = tok { + Some((prefix.as_str(), value)) + } else { + None + } + }) + .collect(); + assert!( + xmlns_decls.iter().any(|(p, v)| *p == "b" + && matches!(v, NbfxText::Chars(s) if s == DC_ASBIDATAV2_NS)), + "expected xmlns:b={DC_ASBIDATAV2_NS:?} on " + ); + assert!( + xmlns_decls.iter().any(|(p, v)| *p == "i" + && matches!(v, NbfxText::Chars(s) if s == XSI_NS)), + "expected xmlns:i={XSI_NS:?} on " + ); + } + #[test] fn delete_subscription_body_carries_subscription_id() { let body = build_delete_subscription_request_body(99);