//! `IAsbCustomSerializableType` binary codecs. //! //! Ports the binary fast-path WCF uses for `Variant` / //! `IAsbCustomSerializableType`-decorated structs. Each type writes a //! `BinaryWriter`-style payload (LE primitives + `AsbBinary` UTF-16 LE //! length-prefixed strings); the WCF `AsbDataCustomSerializer` //! (`AsbContracts.cs:1507-1612`) then base64-encodes that payload and //! wraps it inside an `` element under the field's outer XML //! tag. //! //! ## Scope //! //! Implements: //! * [`ItemIdentity`] — used by RegisterItems / UnregisterItems / Read //! / AddMonitoredItems / DeleteMonitoredItems request bodies. //! //! Stubbed for follow-up F25 iterations: //! * `ItemStatus`, `ItemRegistration`, `WriteValue`, `RuntimeValue` //! payloads, `ItemWriteComplete`, `MonitoredItemSettings`, //! `MonitoredItem`. The pattern is identical — pure binary //! round-trip — so the per-type cost is small once the //! [`ItemIdentity`] reference establishes it. use mxaccess_codec::{AsbStatus, AsbVariant, CodecError, RuntimeValue}; /// `ItemIdentity` per `AsbContracts.cs:533-633`. Wire layout: /// /// | Offset | Size | Field | Notes | /// |-------:|-----:|---------------|--------------------------------------| /// | 0 | 2 | `Type` | u16 `ItemIdentityType` enum | /// | 2 | 2 | `ReferenceType` | u16 `ItemReferenceType` enum | /// | 4 | n | `Name` | `AsbBinary.WriteUnicodeString` | /// | | m | `ContextName` | `AsbBinary.WriteUnicodeString` | /// | | 8 | `Id` | u64 | /// | | 1 | `IdSpecified` | bool (`BinaryWriter.Write(bool)`) | /// /// `AsbBinary.WriteUnicodeString` per `cs:1622-1633`: /// * Null/empty → 4-byte `0u32` length, no payload /// * Non-empty → 4-byte byte-length + UTF-16LE bytes #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ItemIdentity { pub kind: u16, pub reference_type: u16, pub name: Option, pub context_name: Option, pub id: u64, pub id_specified: bool, } /// `ItemIdentityType` enum (`AsbContracts.cs:1295-1300`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u16)] pub enum ItemIdentityType { Name = 0, Id = 1, NameAndId = 2, } /// `ItemReferenceType` enum (`AsbContracts.cs:1302-1308`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u16)] pub enum ItemReferenceType { None = 0, Absolute = 1, Hierarchical = 2, Relative = 3, } impl ItemIdentity { /// Convenience constructor for an absolute name reference. The /// `MxAsbDataClient.CreateAbsoluteItem` path /// (`MxAsbDataClient.cs:172-194`) sets `Type = /// ItemIdentityType.Name`, `ReferenceType = /// ItemReferenceType.Absolute`, and supplies the tag name. Most /// register-time callers use this shape. pub fn absolute_by_name(name: impl Into) -> Self { Self { kind: ItemIdentityType::Name as u16, reference_type: ItemReferenceType::Absolute as u16, name: Some(name.into()), context_name: None, id: 0, id_specified: false, } } pub fn encode_into(&self, out: &mut Vec) { out.extend_from_slice(&self.kind.to_le_bytes()); out.extend_from_slice(&self.reference_type.to_le_bytes()); write_unicode_string(out, self.name.as_deref()); write_unicode_string(out, self.context_name.as_deref()); out.extend_from_slice(&self.id.to_le_bytes()); out.push(if self.id_specified { 1 } else { 0 }); } pub fn encode(&self) -> Vec { let mut out = Vec::new(); self.encode_into(&mut out); out } pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> { let mut cursor = 0usize; let kind = read_u16_le(input, &mut cursor)?; let reference_type = read_u16_le(input, &mut cursor)?; let name = read_unicode_string(input, &mut cursor)?; let context_name = read_unicode_string(input, &mut cursor)?; let id = read_u64_le(input, &mut cursor)?; let id_specified = read_u8(input, &mut cursor)? != 0; Ok(( Self { kind, reference_type, name, context_name, id, id_specified, }, cursor, )) } } /// `ItemStatus` per `AsbContracts.cs:639-722`. Wire layout (from the /// `WriteToStream` method at `cs:682-688`): /// /// | Field | Codec | /// |----------------|-----------------------------| /// | `Item` | [`ItemIdentity`] binary form | /// | `Status` | [`AsbStatus`] binary form | /// | `ErrorCode` | u16 | /// | `ErrorCodeSpecified` | u8 (bool) | /// /// Note the field order on the wire (`Item` then `Status`) is **NOT** /// the `[DataMember(Order = …)]` declared order — `WriteToStream` /// hand-picks Item-first, Status-second, then the trailing pair. /// We mirror that exactly. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ItemStatus { pub item: ItemIdentity, pub status: AsbStatus, pub error_code: u16, pub error_code_specified: bool, } impl ItemStatus { pub fn encode_into(&self, out: &mut Vec) { self.item.encode_into(out); self.status.encode_into(out); out.extend_from_slice(&self.error_code.to_le_bytes()); out.push(if self.error_code_specified { 1 } else { 0 }); } pub fn encode(&self) -> Vec { let mut out = Vec::new(); self.encode_into(&mut out); out } pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> { let (item, item_consumed) = ItemIdentity::decode(input)?; let mut cursor = item_consumed; let status_tail = input.get(cursor..).ok_or(CodecError::ShortRead { expected: 5, actual: 0, })?; let (status, status_consumed) = AsbStatus::decode(status_tail)?; cursor += status_consumed; let error_code = read_u16_le(input, &mut cursor)?; let error_code_specified = read_u8(input, &mut cursor)? != 0; Ok(( Self { item, status, error_code, error_code_specified, }, cursor, )) } } /// Decode an array of `ItemStatus`es from the WCF custom-serializer /// binary form (4-byte int32 count + each item's `WriteToStream` /// output). Mirrors `ItemStatus.InitializeArrayFromStream` /// (`cs:702-711`). pub fn decode_item_status_array(input: &[u8]) -> Result, CodecError> { let mut cursor = 0usize; let count = read_i32_le(input, &mut cursor)?; if count < 0 { return Err(CodecError::Decode { offset: 0, reason: "negative item-status array count", buffer_len: input.len(), }); } 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 (item, consumed) = ItemStatus::decode(tail)?; cursor += consumed; out.push(item); } Ok(out) } /// Encode an array of `ItemStatus`es. Mirrors `ItemStatus.WriteArrayToStream` /// (`cs:713-721`) — 4-byte int32 count + each element's `WriteToStream`. pub fn encode_item_status_array(items: &[ItemStatus]) -> Vec { let mut out = Vec::new(); let count = i32::try_from(items.len()).unwrap_or(i32::MAX); out.extend_from_slice(&count.to_le_bytes()); for item in items { item.encode_into(&mut out); } out } /// `MonitoredItemValue` per `AsbContracts.cs:1032-1104`. /// `IAsbCustomSerializableType` binary fast-path; payload order from /// `WriteToStream` at `cs:1064-1068`: /// /// 1. `Item` — [`ItemIdentity`] binary. /// 2. `Value` — [`RuntimeValue`] binary (timestamp + variant + status). /// 3. `UserData` — [`AsbVariant`] binary. /// /// `MonitoredItemValue` arrives in `PublishResponse` as part of the /// `Values` array — one entry per delivered sample. #[derive(Debug, Clone, PartialEq)] pub struct MonitoredItemValue { pub item: ItemIdentity, pub value: RuntimeValue, pub user_data: AsbVariant, } impl MonitoredItemValue { pub fn encode_into(&self, out: &mut Vec) { self.item.encode_into(out); self.value.encode_into(out); self.user_data.encode_into(out); } pub fn encode(&self) -> Vec { let mut out = Vec::new(); self.encode_into(&mut out); out } pub fn decode(input: &[u8]) -> Result<(Self, usize), CodecError> { let (item, item_consumed) = ItemIdentity::decode(input)?; let mut cursor = item_consumed; let value_tail = input.get(cursor..).ok_or(CodecError::ShortRead { expected: 1, actual: 0, })?; let (value, value_consumed) = RuntimeValue::decode(value_tail)?; cursor += value_consumed; let user_data_tail = input.get(cursor..).ok_or(CodecError::ShortRead { expected: 1, actual: 0, })?; let (user_data, user_data_consumed) = AsbVariant::decode(user_data_tail)?; cursor += user_data_consumed; Ok(( Self { item, value, user_data, }, cursor, )) } } /// Encode a `MonitoredItemValue[]` array per `WriteArrayToStream` /// (`cs:1095-1103`) — 4-byte int32 count + per-element body. pub fn encode_monitored_item_value_array(values: &[MonitoredItemValue]) -> Vec { let mut out = Vec::new(); let count = i32::try_from(values.len()).unwrap_or(i32::MAX); out.extend_from_slice(&count.to_le_bytes()); for v in values { v.encode_into(&mut out); } out } /// Decode a `MonitoredItemValue[]` array. Mirrors /// `MonitoredItemValue.InitializeArrayFromStream` (`cs:1084-1093`). pub fn decode_monitored_item_value_array( input: &[u8], ) -> Result, CodecError> { let mut cursor = 0usize; let count = read_i32_le(input, &mut cursor)?; if count < 0 { return Err(CodecError::Decode { offset: 0, reason: "negative monitored-item-value array count", buffer_len: input.len(), }); } 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 (v, consumed) = MonitoredItemValue::decode(tail)?; cursor += consumed; out.push(v); } Ok(out) } /// Encode an array of `IAsbCustomSerializableType` items per /// `AsbDataCustomSerializer.WriteObjectContent` array branch /// (`AsbContracts.cs:1583-1591` — calls `WriteArrayToStream` which /// emits a 4-byte count followed by each element's `WriteToStream`). pub fn encode_item_identity_array(items: &[ItemIdentity]) -> Vec { let mut out = Vec::new(); let count = i32::try_from(items.len()).unwrap_or(i32::MAX); out.extend_from_slice(&count.to_le_bytes()); for item in items { item.encode_into(&mut out); } out } /// Decode an array of `ItemIdentity`s from the WCF custom-serializer /// binary form (4-byte count + items). Mirrors /// `ItemIdentity.InitializeArrayFromStream` (`cs:614-623`). pub fn decode_item_identity_array(input: &[u8]) -> Result, CodecError> { let mut cursor = 0usize; let count = read_i32_le(input, &mut cursor)?; if count < 0 { return Err(CodecError::Decode { offset: 0, reason: "negative item-identity array count", buffer_len: input.len(), }); } 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 (item, consumed) = ItemIdentity::decode(tail)?; cursor += consumed; out.push(item); } Ok(out) } // ---- AsbBinary helpers --------------------------------------------------- /// Mirror `AsbBinary.WriteUnicodeString` at `cs:1622-1633`. Null/empty /// strings emit a 4-byte `0u32` length and no payload bytes. fn write_unicode_string(out: &mut Vec, value: Option<&str>) { let s = value.unwrap_or(""); if s.is_empty() { out.extend_from_slice(&0u32.to_le_bytes()); return; } let mut utf16 = Vec::with_capacity(s.len() * 2); for unit in s.encode_utf16() { utf16.extend_from_slice(&unit.to_le_bytes()); } let len = u32::try_from(utf16.len()).unwrap_or(u32::MAX); out.extend_from_slice(&len.to_le_bytes()); out.extend_from_slice(&utf16); } /// Mirror `AsbBinary.ReadUnicodeString` at `cs:1616-1620`. Length 0 /// returns `None` (matches `string.Empty` in .NET — both forms collapse /// to a Rust `None` here so callers can distinguish unset from empty by /// asserting on the original string). fn read_unicode_string(input: &[u8], cursor: &mut usize) -> Result, CodecError> { let len = read_u32_le(input, cursor)? as usize; if len == 0 { return Ok(None); } if len % 2 != 0 { return Err(CodecError::Decode { offset: *cursor, reason: "unicode string length is odd", buffer_len: input.len(), }); } let bytes = input .get(*cursor..*cursor + len) .ok_or(CodecError::ShortRead { expected: len, actual: input.len().saturating_sub(*cursor), })?; let mut units = Vec::with_capacity(len / 2); for chunk in bytes.chunks_exact(2) { let mut buf = [0u8; 2]; buf.copy_from_slice(chunk); units.push(u16::from_le_bytes(buf)); } let s = String::from_utf16(&units).map_err(|_| CodecError::Decode { offset: *cursor, reason: "invalid UTF-16 in unicode string", buffer_len: input.len(), })?; *cursor += len; Ok(Some(s)) } fn read_u16_le(input: &[u8], cursor: &mut usize) -> Result { let bytes = read_array::<2>(input, cursor)?; Ok(u16::from_le_bytes(bytes)) } fn read_u32_le(input: &[u8], cursor: &mut usize) -> Result { let bytes = read_array::<4>(input, cursor)?; Ok(u32::from_le_bytes(bytes)) } fn read_i32_le(input: &[u8], cursor: &mut usize) -> Result { let bytes = read_array::<4>(input, cursor)?; Ok(i32::from_le_bytes(bytes)) } fn read_u64_le(input: &[u8], cursor: &mut usize) -> Result { let bytes = read_array::<8>(input, cursor)?; Ok(u64::from_le_bytes(bytes)) } fn read_u8(input: &[u8], cursor: &mut usize) -> Result { let byte = *input.get(*cursor).ok_or(CodecError::ShortRead { expected: 1, actual: 0, })?; *cursor += 1; Ok(byte) } fn read_array(input: &[u8], cursor: &mut usize) -> Result<[u8; N], CodecError> { let slice = input .get(*cursor..*cursor + N) .ok_or(CodecError::ShortRead { expected: N, actual: input.len().saturating_sub(*cursor), })?; let mut out = [0u8; N]; out.copy_from_slice(slice); *cursor += N; Ok(out) } #[cfg(test)] #[allow( clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing )] mod tests { use super::*; fn round_trip(item: ItemIdentity) { let bytes = item.encode(); let (decoded, consumed) = ItemIdentity::decode(&bytes).unwrap(); assert_eq!(consumed, bytes.len()); assert_eq!(decoded, item); } #[test] fn item_identity_round_trip_default() { round_trip(ItemIdentity::default()); } #[test] fn item_identity_round_trip_absolute_by_name() { round_trip(ItemIdentity::absolute_by_name("TestChildObject.TestInt")); } #[test] fn item_identity_round_trip_with_id() { round_trip(ItemIdentity { kind: ItemIdentityType::NameAndId as u16, reference_type: ItemReferenceType::Absolute as u16, name: Some("TestChildObject.TestInt".to_string()), context_name: Some("TestObject".to_string()), id: 0x1234_5678_9abc_def0, id_specified: true, }); } #[test] fn item_identity_round_trip_unicode_name() { round_trip(ItemIdentity::absolute_by_name("TéstObj.Φοο")); } #[test] fn item_identity_byte_layout_minimum_19_bytes() { // Empty Name + empty ContextName + Id=0 + IdSpecified=false: // 2 (kind) + 2 (refType) + 4 (name len=0) + 4 (ctx len=0) // + 8 (id) + 1 (idSpecified) = 21 bytes. let item = ItemIdentity::default(); let bytes = item.encode(); assert_eq!(bytes.len(), 21); } #[test] fn unicode_string_round_trip_handles_null_empty_and_value() { // Null let mut buf = Vec::new(); write_unicode_string(&mut buf, None); let mut c = 0; assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None); // Empty let mut buf = Vec::new(); write_unicode_string(&mut buf, Some("")); let mut c = 0; assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None); // ASCII let mut buf = Vec::new(); write_unicode_string(&mut buf, Some("hi")); let mut c = 0; assert_eq!( read_unicode_string(&buf, &mut c).unwrap(), Some("hi".to_string()) ); } #[test] fn item_identity_array_round_trip() { let items = vec![ ItemIdentity::absolute_by_name("Tag.A"), ItemIdentity::absolute_by_name("Tag.B"), ItemIdentity::absolute_by_name("Tag.C"), ]; let bytes = encode_item_identity_array(&items); let decoded = decode_item_identity_array(&bytes).unwrap(); assert_eq!(decoded, items); } #[test] fn item_identity_array_empty() { let bytes = encode_item_identity_array(&[]); // 4 bytes (count = 0) assert_eq!(bytes.len(), 4); assert_eq!( decode_item_identity_array(&bytes).unwrap(), Vec::::new() ); } #[test] fn item_status_round_trip() { let s = ItemStatus { item: ItemIdentity::absolute_by_name("Tag.X"), status: AsbStatus { count: -1, payload: vec![0xC0], }, error_code: 0x1234, error_code_specified: true, }; let bytes = s.encode(); let (decoded, consumed) = ItemStatus::decode(&bytes).unwrap(); assert_eq!(consumed, bytes.len()); assert_eq!(decoded, s); } #[test] fn item_status_array_round_trip() { let arr = vec![ ItemStatus::default(), ItemStatus { item: ItemIdentity::absolute_by_name("Tag.A"), status: AsbStatus { count: 1, payload: vec![0x01, 0x02], }, error_code: 42, error_code_specified: true, }, ]; let bytes = encode_item_status_array(&arr); let decoded = decode_item_status_array(&bytes).unwrap(); assert_eq!(decoded, arr); } #[test] fn monitored_item_value_round_trip() { let mv = MonitoredItemValue { item: ItemIdentity::absolute_by_name("Tag.X"), value: RuntimeValue { timestamp_binary: 0x0123_4567, timestamp_specified: true, value: AsbVariant::from_i32(100), status: AsbStatus::default(), }, user_data: AsbVariant::empty(), }; let bytes = mv.encode(); let (decoded, consumed) = MonitoredItemValue::decode(&bytes).unwrap(); assert_eq!(consumed, bytes.len()); assert_eq!(decoded, mv); } #[test] fn monitored_item_value_array_round_trip() { let arr = vec![ MonitoredItemValue { item: ItemIdentity::absolute_by_name("Tag.A"), value: RuntimeValue { timestamp_binary: 1, timestamp_specified: true, value: AsbVariant::from_i32(1), status: AsbStatus::default(), }, user_data: AsbVariant::empty(), }, MonitoredItemValue { item: ItemIdentity::absolute_by_name("Tag.B"), value: RuntimeValue { timestamp_binary: 2, timestamp_specified: false, value: AsbVariant::from_string("hello"), status: AsbStatus { count: 1, payload: vec![0xC0], }, }, user_data: AsbVariant::from_bool(true), }, ]; let bytes = encode_monitored_item_value_array(&arr); let decoded = decode_monitored_item_value_array(&bytes).unwrap(); assert_eq!(decoded, arr); } #[test] fn item_identity_array_count_is_le_int32() { let items = vec![ItemIdentity::default(); 7]; let bytes = encode_item_identity_array(&items); // First 4 bytes = 7 little-endian. assert_eq!(&bytes[0..4], &[0x07, 0x00, 0x00, 0x00]); } }