[F34] mxaccess-asb: AddMonitoredItems body uses DataContract field names
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 <Items> wrapper now declares xmlns:b + xmlns:i. The legacy XmlSerializer property names (<Active>, <Item>, <SampleInterval>, <Buffered>) 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 <i:nil> 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 <ASBIData> 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) <noreply@anthropic.com>
This commit is contained in:
+7
-3
@@ -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 <hash>` provides.
|
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 <hash>` provides.
|
||||||
|
|
||||||
### F34 — `MonitoredItem` wire format: DataContract field-suffix names, not XmlSerializer property names
|
### 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.
|
**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 `<ASBIData/>` 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 `<Status><ASBIData/></Status><Values><ASBIData>{bytes}</ASBIData></Values>` — 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 `<ASBIData>` 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.**
|
**Closed: `decode_publish_response` filtered empty `<ASBIData/>` 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 `<Status><ASBIData/></Status><Values><ASBIData>{bytes}</ASBIData></Values>` — 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 `<ASBIData>` 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 `<Active>`, `<Buffered>`, `<Item>`, `<SampleInterval>` — 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 (`<MonitoredItem>`, `<Item>`, `<SampleInterval>`, `<Active>`, `<Buffered>`) with the WCF DataContract field-suffix names (`<b:MonitoredItem>`, `<b:activeField>`, `<b:activeFieldSpecified>`, `<b:bufferedField>`, `<b:itemField>` (with nested ItemIdentity DC fields), `<b:sampleIntervalField>`, `<b:timeDeadbandField>`, `<b:timeDeadbandFieldSpecified>`, `<b:userDataField>` (Variant), `<b:valueDeadbandField>` (Variant)) emitted under prefix `b` bound to `http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract`. The `<Items>` 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 `<i:nil>` 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 `<Active>`, `<Buffered>`, `<Item>`, `<SampleInterval>` — 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:
|
**The dual-format world**: ASB requests have *two* element-name conventions on the wire:
|
||||||
- **HMAC canonical XML** (input to `AsbAuthenticator::Sign`): XmlSerializer-derived names — `<Active>`, `<Items>`, `<MonitoredItem>` etc. Driven by `[XmlElement(...)]` and property names. Our `xml_canonical` emitter is byte-equal to .NET here (F28 step 2 fixtures verify).
|
- **HMAC canonical XML** (input to `AsbAuthenticator::Sign`): XmlSerializer-derived names — `<Active>`, `<Items>`, `<MonitoredItem>` etc. Driven by `[XmlElement(...)]` and property names. Our `xml_canonical` emitter is byte-equal to .NET here (F28 step 2 fixtures verify).
|
||||||
|
|||||||
@@ -335,11 +335,18 @@ impl AsbAuthenticator {
|
|||||||
out.as_mut_slice(),
|
out.as_mut_slice(),
|
||||||
);
|
);
|
||||||
if std::env::var("MX_ASB_TRACE_DERIVE").ok().is_some() {
|
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());
|
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.hex={hex}");
|
||||||
eprintln!("asb.derive.crypto_key.b64={password_b64}");
|
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}");
|
eprintln!("asb.derive.aes_key.hex={aes_hex}");
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
@@ -947,9 +947,13 @@ fn detect_soap_fault(decoded: &crate::DecodedEnvelope) -> Option<ClientError> {
|
|||||||
/// Hex dump for diagnostic traces. First 256 bytes only to keep
|
/// Hex dump for diagnostic traces. First 256 bytes only to keep
|
||||||
/// MX_ASB_TRACE_REPLY output bounded.
|
/// MX_ASB_TRACE_REPLY output bounded.
|
||||||
fn hex_dump(bytes: &[u8]) -> String {
|
fn hex_dump(bytes: &[u8]) -> String {
|
||||||
|
use std::fmt::Write as _;
|
||||||
let cap = bytes.len().min(256);
|
let cap = bytes.len().min(256);
|
||||||
let slice = bytes.get(..cap).unwrap_or(&[]);
|
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 ----------------------------------------------------------
|
// ---- error type ----------------------------------------------------------
|
||||||
|
|||||||
@@ -517,6 +517,14 @@ pub fn build_delete_monitored_items_request_body(
|
|||||||
prefix: None,
|
prefix: None,
|
||||||
name: NbfxName::Inline("Items".to_string()),
|
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 {
|
for item in items {
|
||||||
push_monitored_item_body(&mut tokens, item);
|
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::Text(NbfxText::Int64(subscription_id)),
|
||||||
NbfxToken::EndElement,
|
NbfxToken::EndElement,
|
||||||
// <Items>
|
// <Items xmlns:b="<DC namespace>" xmlns:i="<xsi namespace>">
|
||||||
NbfxToken::Element {
|
NbfxToken::Element {
|
||||||
prefix: None,
|
prefix: None,
|
||||||
name: NbfxName::Inline("Items".to_string()),
|
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 {
|
for item in items {
|
||||||
push_monitored_item_body(&mut tokens, item);
|
push_monitored_item_body(&mut tokens, item);
|
||||||
@@ -788,55 +804,185 @@ pub fn build_add_monitored_items_request_body(
|
|||||||
tokens
|
tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit a single `<MonitoredItem>...</MonitoredItem>` NBFX subtree.
|
/// Emit a single `<b:MonitoredItem>...</b:MonitoredItem>` NBFX subtree.
|
||||||
/// Shared between AddMonitoredItems and DeleteMonitoredItems request
|
/// Shared between AddMonitoredItems and DeleteMonitoredItems request
|
||||||
/// builders. Field order matches the .NET `MonitoredItem` declaration:
|
/// builders.
|
||||||
/// Item / SampleInterval / Active (when Specified) / Buffered.
|
///
|
||||||
|
/// **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 (`<Active>`, `<Item>`, 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<NbfxToken>, item: &MinimalMonitoredItem) {
|
fn push_monitored_item_body(tokens: &mut Vec<NbfxToken>, item: &MinimalMonitoredItem) {
|
||||||
tokens.push(NbfxToken::Element {
|
tokens.push(NbfxToken::Element {
|
||||||
prefix: None,
|
prefix: Some("b".to_string()),
|
||||||
name: NbfxName::Inline("MonitoredItem".to_string()),
|
name: NbfxName::Inline("MonitoredItem".to_string()),
|
||||||
});
|
});
|
||||||
// <Item><ASBIData>{ItemIdentity binary}</ASBIData></Item>
|
// 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 <ASBIData> 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); // </b:MonitoredItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{bool}</b:{name}>` — Bool text record (with-end-element
|
||||||
|
/// variant chosen by the encoder).
|
||||||
|
fn push_b_bool(tokens: &mut Vec<NbfxToken>, name: &str, value: bool) {
|
||||||
tokens.push(NbfxToken::Element {
|
tokens.push(NbfxToken::Element {
|
||||||
prefix: None,
|
prefix: Some("b".to_string()),
|
||||||
name: NbfxName::Inline("Item".to_string()),
|
name: NbfxName::Inline(name.to_string()),
|
||||||
});
|
});
|
||||||
tokens.push(NbfxToken::Element {
|
tokens.push(NbfxToken::Text(NbfxText::Bool(value)));
|
||||||
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);
|
tokens.push(NbfxToken::EndElement);
|
||||||
// <Active> — emitted only when ActiveSpecified=true
|
}
|
||||||
// (`MonitoredItem.Active` setter at `AsbContracts.cs:982-987`).
|
|
||||||
// Required to make MxDataProvider actually deliver values; F34.
|
/// `<b:{name}>{ulong-as-text}</b:{name}>` — WCF emits `ulong` via
|
||||||
if let Some(active) = item.active {
|
/// `XmlConvert.ToString` (decimal text) which the binary writer then
|
||||||
tokens.push(NbfxToken::Element {
|
/// encodes as `Chars8`. Values 0 and 1 collapse to the dedicated
|
||||||
prefix: None,
|
/// `ZeroText` / `OneText` records that the WCF binary writer prefers
|
||||||
name: NbfxName::Inline("Active".to_string()),
|
/// when the text would be `"0"` / `"1"`.
|
||||||
});
|
fn push_b_ulong_text(tokens: &mut Vec<NbfxToken>, name: &str, value: u64) {
|
||||||
tokens.push(NbfxToken::Text(NbfxText::Bool(active)));
|
tokens.push(NbfxToken::Element {
|
||||||
tokens.push(NbfxToken::EndElement);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{ushort-as-binary}</b:{name}>` — `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<NbfxToken>, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{string-or-empty-element}</b:{name}>` — 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<NbfxToken>, 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())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// <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);
|
||||||
tokens.push(NbfxToken::EndElement); // </MonitoredItem>
|
}
|
||||||
|
|
||||||
|
/// 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<NbfxToken>, 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); // </b:itemField>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<NbfxToken>, 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 `<i:nil>` 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); // </b:{name}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<b:{name}>{int32}</b:{name}>` — 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<NbfxToken>, 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`,
|
/// Minimal `MonitoredItem` shape covering `Item`, `SampleInterval`,
|
||||||
@@ -1457,6 +1603,19 @@ pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec<NbfxTo
|
|||||||
|
|
||||||
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
||||||
|
|
||||||
|
/// DataContract namespace for `MonitoredItem` / `ItemIdentity` /
|
||||||
|
/// `Variant` etc. Source: `[DataContract(Namespace = "...")]` on each
|
||||||
|
/// type at `AsbContracts.cs:533, 936, 1170`. F34: this is the wire
|
||||||
|
/// namespace for nested DataContract members emitted under the `b`
|
||||||
|
/// prefix inside `<Items>` / `<Values>` payloads.
|
||||||
|
const DC_ASBIDATAV2_NS: &str =
|
||||||
|
"http://schemas.datacontract.org/2004/07/ArchestrAServices.ASBIDataV2Contract";
|
||||||
|
|
||||||
|
/// `xsi` namespace, declared (often unused) on the `<Items>` 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)]
|
#[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`).
|
#[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 {
|
enum BodyField {
|
||||||
@@ -2570,6 +2729,148 @@ mod tests {
|
|||||||
assert!(saw_monitored_item);
|
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 <b:MonitoredItem>, 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<usize> = expected_dc_fields
|
||||||
|
.iter()
|
||||||
|
.map(|f| {
|
||||||
|
elements
|
||||||
|
.iter()
|
||||||
|
.position(|(p, n)| *p == Some("b") && n == f)
|
||||||
|
.unwrap_or_else(|| panic!("missing <b:{f}> 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 <ASBIData>
|
||||||
|
// 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 <b:{ii_field}> 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 <b:lengthField> (userData + valueDeadband Variants)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload_count, 2,
|
||||||
|
"expected 2x <b:payloadField> (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 <Items> wrapper declares `xmlns:b` (DC namespace) and
|
||||||
|
// `xmlns:i` (XSI). Verified by scanning for NamespaceDeclaration
|
||||||
|
// tokens immediately following the `<Items>` 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 <Items>"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
xmlns_decls.iter().any(|(p, v)| *p == "i"
|
||||||
|
&& matches!(v, NbfxText::Chars(s) if s == XSI_NS)),
|
||||||
|
"expected xmlns:i={XSI_NS:?} on <Items>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_subscription_body_carries_subscription_id() {
|
fn delete_subscription_body_carries_subscription_id() {
|
||||||
let body = build_delete_subscription_request_body(99);
|
let body = build_delete_subscription_request_body(99);
|
||||||
|
|||||||
Reference in New Issue
Block a user