diff --git a/design/followups.md b/design/followups.md
index 5547f60..340eac1 100644
--- a/design/followups.md
+++ b/design/followups.md
@@ -46,7 +46,11 @@ move to `## Resolved` with a date + commit hash.
**Resolves when:** F19-F26 are all closed and the four DoD bullets above pass.
-**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 (`1b1ee1e`); F26 step 1 (`8a0f92b`); F26 step 2 (`14bb529`); example rewrite (`c6570dc`); F25 step 8 landed in this commit:
+**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 (`1b1ee1e`); F26 step 1 (`8a0f92b`); F26 step 2 (`14bb529`); example rewrite (`c6570dc`); F25 step 8 (`b543eb1`); F25 step 9 landed in this commit:
+- F25 step 9: Write operation. New `MinimalWriteValue { value: AsbVariant }` carries just the `Value` payload; optional ArrayElementIndex/Comment/HasQT/Status/Timestamp WriteValue fields are deferred to a later iteration once a live capture confirms the WCF DataContract XML form. New `build_write_request_body(items, values, write_handle)` produces the full `WriteBasicRequest` body shape per `AsbContracts.cs:181-194`: Items array uses the IAsbCustomSerializableType binary fast-path (`{...}`), each Value's inner `Variant` field also uses the fast-path (`{...}`), and WriteHandle is an Int32. New `decode_write_response` returns the per-item Status array. New `client::write(items, values, write_handle)` wrapper. 4 new tests cover Write request body shape (carries Items array, parallel Values array with WriteValue elements, WriteHandle as Int32), parallel-array sizing (2 items + 2 values produces 2 WriteValue elements), Status round-trip, and missing-Status error. Workspace: 695 tests pass (was 691, +4). The IASBIDataV2 read+write+subscribe path is now functionally complete in-memory.
+
+**Earlier slices:**
+- F25 step 8 (commit `b543eb1`):
- F25 step 8: subscription operations — `CreateSubscription`, `AddMonitoredItems`, `Publish`, `DeleteSubscription`. New `MonitoredItemValue` codec in contracts.rs (`IAsbCustomSerializableType` binary fast-path: ItemIdentity + RuntimeValue + AsbVariant per `cs:1064-1068`). New `MinimalMonitoredItem` request struct exposing only the proven fields (Item, SampleInterval, Buffered) — optional Active/TimeDeadband/ValueDeadband/UserData deferred to a later iteration once a live capture confirms the WCF DataContract XML shape. Per-operation builders, response decoders, and client wrappers follow the established F25 pattern. New `BodyField::Int64Element` variant for the `` / `` / `` primitive fields. The subscription path lifts the `examples/asb-subscribe.rs` "Read-loop" caveat — once wire-byte reconciliation lands, the example can do `create_subscription → add_monitored_items → publish-loop → delete_subscription`. 11 new tests cover MonitoredItemValue round-trip + array, CreateSubscription request body shape + response decode (Int64 + Chars text fallback + missing-field error), AddMonitoredItems request body shape + response decode, DeleteSubscription request body, Publish request + response (with full Status + Values round-trip via the in-memory body synthesis pattern).
**Earlier slices:**
diff --git a/rust/crates/mxaccess-asb/src/client.rs b/rust/crates/mxaccess-asb/src/client.rs
index 45ebd4a..f1a3c83 100644
--- a/rust/crates/mxaccess-asb/src/client.rs
+++ b/rust/crates/mxaccess-asb/src/client.rs
@@ -56,16 +56,16 @@ use crate::contracts::{ItemIdentity, ItemStatus};
use crate::envelope::{ConnectionValidator, EnvelopeError, SoapEnvelope};
use crate::operations::{
AddMonitoredItemsResponse, ConnectResponse, CreateSubscriptionResponse,
- DeleteSubscriptionResponse, MinimalMonitoredItem, OperationError, PublishResponse,
- ReadResponse, RegisterItemsResponse, UnregisterItemsResponse,
+ DeleteSubscriptionResponse, MinimalMonitoredItem, MinimalWriteValue, OperationError,
+ PublishResponse, ReadResponse, RegisterItemsResponse, UnregisterItemsResponse, WriteResponse,
build_add_monitored_items_request_body, build_authenticate_me_request_body,
build_connect_request_body, build_create_subscription_request_body,
build_delete_subscription_request_body, build_disconnect_request_body,
build_keep_alive_request_body, build_publish_request_body, build_read_request_body,
build_register_items_request_body, build_unregister_items_request_body,
- decode_add_monitored_items_response, decode_connect_response,
+ build_write_request_body, decode_add_monitored_items_response, decode_connect_response,
decode_create_subscription_response, decode_publish_response, decode_read_response,
- decode_register_items_response, decode_unregister_items_response,
+ decode_register_items_response, decode_unregister_items_response, decode_write_response,
};
use crate::{actions, decode_envelope, encode_envelope};
@@ -341,6 +341,27 @@ impl AsbClient {
Ok(decode_read_response(&response.body_tokens)?)
}
+ /// `Write` operation — sends a signed `WriteIn` SOAP envelope and
+ /// decodes the `WriteResponse` (per-item Status array).
+ ///
+ /// `items.len()` must equal `values.len()`; the .NET reference
+ /// pairs them positionally per `MxAsbDataClient.cs` Write path.
+ /// `write_handle` is an opaque correlation ID echoed in the
+ /// PublishWriteComplete callback (irrelevant for fire-and-forget
+ /// writes; pass `0`).
+ pub async fn write(
+ &mut self,
+ items: &[ItemIdentity],
+ values: &[MinimalWriteValue],
+ write_handle: u32,
+ ) -> Result {
+ let body = build_write_request_body(items, values, write_handle);
+ let response = self
+ .send_signed_envelope(actions::WRITE, body, false)
+ .await?;
+ Ok(decode_write_response(&response.body_tokens)?)
+ }
+
/// `CreateSubscription` operation — allocates a server-side
/// subscription and returns its ID. Caller threads the ID through
/// subsequent `add_monitored_items` / `publish` /
diff --git a/rust/crates/mxaccess-asb/src/lib.rs b/rust/crates/mxaccess-asb/src/lib.rs
index 6000dae..c828cfa 100644
--- a/rust/crates/mxaccess-asb/src/lib.rs
+++ b/rust/crates/mxaccess-asb/src/lib.rs
@@ -27,14 +27,15 @@ pub use envelope::{
};
pub use operations::{
AddMonitoredItemsResponse, AuthenticationDataBytes, ConnectResponse,
- CreateSubscriptionResponse, DeleteSubscriptionResponse, MinimalMonitoredItem, OperationError,
- PublishResponse, ReadResponse, RegisterItemsResponse, UnregisterItemsResponse,
- build_add_monitored_items_request_body, build_authenticate_me_request_body,
- build_connect_request_body, build_create_subscription_request_body,
- build_delete_subscription_request_body, build_disconnect_request_body,
- build_keep_alive_request_body, build_publish_request_body, build_read_request_body,
- build_register_items_request_body, build_unregister_items_request_body,
- collect_asbidata_payloads, decode_add_monitored_items_response, decode_connect_response,
+ CreateSubscriptionResponse, DeleteSubscriptionResponse, MinimalMonitoredItem,
+ MinimalWriteValue, OperationError, PublishResponse, ReadResponse, RegisterItemsResponse,
+ UnregisterItemsResponse, WriteResponse, build_add_monitored_items_request_body,
+ build_authenticate_me_request_body, build_connect_request_body,
+ build_create_subscription_request_body, build_delete_subscription_request_body,
+ build_disconnect_request_body, build_keep_alive_request_body, build_publish_request_body,
+ build_read_request_body, build_register_items_request_body,
+ build_unregister_items_request_body, build_write_request_body, collect_asbidata_payloads,
+ decode_add_monitored_items_response, decode_connect_response,
decode_create_subscription_response, decode_publish_response, decode_read_response,
- decode_register_items_response, decode_unregister_items_response,
+ decode_register_items_response, decode_unregister_items_response, decode_write_response,
};
diff --git a/rust/crates/mxaccess-asb/src/operations.rs b/rust/crates/mxaccess-asb/src/operations.rs
index 69f22a6..904ceca 100644
--- a/rust/crates/mxaccess-asb/src/operations.rs
+++ b/rust/crates/mxaccess-asb/src/operations.rs
@@ -395,6 +395,114 @@ fn find_inline_text(
None
}
+// ---- 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 (`` Bytes
+/// record); the Values array is per-WriteValue regular XML — though
+/// the Variant inside each WriteValue/Value field IS
+/// IAsbCustomSerializableType so it gets `` 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 {
+ 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, //
+ NbfxToken::EndElement, //
+ 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); //
+ tokens.push(NbfxToken::EndElement); //
+ tokens.push(NbfxToken::EndElement); //
+ }
+ tokens.push(NbfxToken::EndElement); //
+ 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); //
+ tokens
+}
+
+/// Decoded `WriteResponse`. Mirrors `AsbContracts.cs:196-202` — just
+/// the per-item Status array.
+#[derive(Debug, Clone, PartialEq)]
+pub struct WriteResponse {
+ pub status: Vec,
+}
+
+pub fn decode_write_response(body_tokens: &[NbfxToken]) -> Result {
+ let payload = collect_asbidata_payloads(body_tokens, "Status")
+ .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
@@ -1601,6 +1709,89 @@ mod tests {
assert!(decoded.values.is_empty());
}
+ #[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 .
+ 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);