diff --git a/clients/rust/README.md b/clients/rust/README.md index 934898e..75a27e1 100644 --- a/clients/rust/README.md +++ b/clients/rust/README.md @@ -168,6 +168,39 @@ the unchanged elements included. For example, to change 2 elements of a the 2 new ones). Sending only the 2 changed values overwrites the attribute with a 2-element array. +#### Default-fill partial array writes + +When you only need to set a handful of indices and want every other position to +take the element type's default (zero / `false` / empty string / Unix epoch for +timestamps), use `Session::write_array_elements` instead: + +```rust +// Write a 10-element integer array; index 0 = 42, index 7 = 99, +// all other indices default to 0 (not preserved from the previous value). +session + .write_array_elements( + server_handle, + item_handle, + MxDataType::Integer, + 10, + [(0, MxValue::int32(42)), (7, MxValue::int32(99))], + user_id, + ) + .await?; +``` + +The gateway expands the sparse representation into a full `MxArray` before +forwarding to the worker — the worker and MXAccess COM never see the sparse +form. Unmentioned indices are reset to the type default, **not** preserved from +the existing attribute value. + +#### Bare-name array AddItem normalisation + +`AddItem` for a bare array attribute name (e.g. `Tank01.Temperature`) is +automatically normalised to `Tank01.Temperature[]` by the gateway so the +worker can resolve the full array. You do not need to append `[]` in client +code; the gateway handles it. + ## Galaxy Repository browse The Galaxy Repository service exposes a read-only browse over the AVEVA System diff --git a/clients/rust/src/session.rs b/clients/rust/src/session.rs index 1179a92..7f59d71 100644 --- a/clients/rust/src/session.rs +++ b/clients/rust/src/session.rs @@ -17,12 +17,12 @@ use crate::generated::mxaccess_gateway::v1::mx_command_reply; use crate::generated::mxaccess_gateway::v1::{ AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply, - MxCommandRequest, MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, - RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, StreamEventsRequest, - SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, UnAdviseItemBulkCommand, - UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, Write2Command, WriteBulkCommand, - WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, WriteSecured2BulkEntry, - WriteSecuredBulkCommand, WriteSecuredBulkEntry, + MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue, + OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, + StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, + UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, + Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, + WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry, }; use crate::value::MxValue; @@ -547,6 +547,62 @@ impl Session { Ok(()) } + /// Write a sparse, default-filled array: only the given elements + /// (index → scalar value) are set; every unmentioned index up to + /// `total_length` is written as the element type's default (a reset, + /// **not** a preserve). The gateway expands the sparse representation into + /// a whole-array write before forwarding to the worker. + /// + /// This is a convenience wrapper around [`Session::write`] that builds the + /// `MxSparseArray` wire value for you. Call [`Session::write`] directly + /// if you need to pass a pre-built [`MxValue`] carrying a full + /// `MxArray`. + /// + /// # Errors + /// + /// Returns [`Error::InvalidArgument`] (propagated from the gateway) if + /// `total_length` is zero, exceeds the gateway's maximum array length, or + /// any element index is out of range. Returns [`Error::Command`] for + /// non-OK worker statuses, plus the usual transport/status errors. + pub async fn write_array_elements( + &self, + server_handle: i32, + item_handle: i32, + element_data_type: MxDataType, + total_length: u32, + elements: impl IntoIterator, + user_id: i32, + ) -> Result<(), Error> { + use crate::generated::mxaccess_gateway::v1::mx_value::Kind; + + let sparse_elements: Vec = elements + .into_iter() + .map(|(index, value)| MxSparseElement { + index, + value: Some(value.into_proto()), + }) + .collect(); + + let sparse_value = ProtoMxValue { + data_type: element_data_type as i32, + variant_type: String::new(), + kind: Some(Kind::SparseArrayValue(MxSparseArray { + element_data_type: element_data_type as i32, + total_length, + elements: sparse_elements, + })), + ..ProtoMxValue::default() + }; + + self.write( + server_handle, + item_handle, + MxValue::from_proto(sparse_value), + user_id, + ) + .await + } + /// Run MXAccess `Write2` (single-value with caller-supplied timestamp). /// /// # Errors diff --git a/clients/rust/src/value.rs b/clients/rust/src/value.rs index 4da694a..94e43d1 100644 --- a/clients/rust/src/value.rs +++ b/clients/rust/src/value.rs @@ -173,7 +173,11 @@ impl MxValueProjection { Some(Kind::TimestampValue(value)) => Self::Timestamp(*value), Some(Kind::ArrayValue(value)) => Self::Array(MxArrayValue::from_proto(value.clone())), Some(Kind::RawValue(value)) => Self::Raw(value.clone()), - None => Self::Unset, + // SparseArrayValue is write-only: the gateway expands it before forwarding + // to the worker and never emits it in events or read replies. Map it to + // Unset so any read-side code that encounters a stale or mis-routed + // sparse value degrades gracefully rather than panicking. + Some(Kind::SparseArrayValue(_)) | None => Self::Unset, } } } diff --git a/clients/rust/tests/client_behavior.rs b/clients/rust/tests/client_behavior.rs index 33a1092..565a7ad 100644 --- a/clients/rust/tests/client_behavior.rs +++ b/clients/rust/tests/client_behavior.rs @@ -24,8 +24,8 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{ AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind, BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent, - MxEventFamily, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue, - OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus, + MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, + MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus, ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, SessionState, StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry, @@ -1091,3 +1091,92 @@ fn case_by_id<'a>(cases: &'a [Value], id: &str) -> &'a Value { .find(|case| case["id"].as_str() == Some(id)) .unwrap_or_else(|| panic!("missing fixture case {id}")) } + +// --------------------------------------------------------------------------- +// write_array_elements — proto shape unit tests +// --------------------------------------------------------------------------- + +/// Build the proto `MxValue` that `write_array_elements` would send and assert +/// the sparse oneof variant has the correct `total_length` and elements. +fn sparse_int32_value( + total_length: u32, + elements: impl IntoIterator, +) -> MxValue { + let sparse_elements: Vec = elements + .into_iter() + .map(|(index, v)| MxSparseElement { + index, + value: Some(MxValue { + data_type: MxDataType::Integer as i32, + variant_type: "VT_I4".to_owned(), + kind: Some(Kind::Int32Value(v)), + ..MxValue::default() + }), + }) + .collect(); + + MxValue { + data_type: MxDataType::Integer as i32, + variant_type: String::new(), + kind: Some(Kind::SparseArrayValue(MxSparseArray { + element_data_type: MxDataType::Integer as i32, + total_length, + elements: sparse_elements, + })), + ..MxValue::default() + } +} + +#[test] +fn write_array_elements_proto_shape_has_sparse_oneof_kind() { + let proto = sparse_int32_value(5, [(0, 10), (3, 30)]); + + let Kind::SparseArrayValue(ref sparse) = proto.kind.as_ref().unwrap() else { + panic!("expected SparseArrayValue kind, got {:?}", proto.kind); + }; + + assert_eq!(sparse.total_length, 5, "total_length must round-trip"); + assert_eq!(sparse.elements.len(), 2, "two elements supplied"); + assert_eq!(sparse.element_data_type, MxDataType::Integer as i32); + + let elem0 = &sparse.elements[0]; + assert_eq!(elem0.index, 0); + assert_eq!( + elem0.value.as_ref().unwrap().kind, + Some(Kind::Int32Value(10)) + ); + + let elem3 = &sparse.elements[1]; + assert_eq!(elem3.index, 3); + assert_eq!( + elem3.value.as_ref().unwrap().kind, + Some(Kind::Int32Value(30)) + ); +} + +#[test] +fn write_array_elements_empty_elements_is_valid_all_defaults() { + let proto = sparse_int32_value(8, []); + let Kind::SparseArrayValue(ref sparse) = proto.kind.as_ref().unwrap() else { + panic!("expected SparseArrayValue kind"); + }; + assert_eq!(sparse.total_length, 8); + assert!( + sparse.elements.is_empty(), + "no elements means every index defaults" + ); +} + +#[test] +fn sparse_array_value_round_trips_through_client_mx_value_projection_as_unset() { + // SparseArrayValue is write-only. If it ever arrives on the read path + // (e.g. a future version bug), the projection should degrade to Unset + // rather than panic, because the enum variant is not readable. + let proto = sparse_int32_value(4, [(1, 99)]); + let client_value = ClientMxValue::from_proto(proto); + assert_eq!( + client_value.projection(), + &MxValueProjection::Unset, + "write-only SparseArrayValue must project to Unset, not panic" + ); +}