b543eb1f84
CreateSubscription / AddMonitoredItems / Publish / DeleteSubscription. Completes the IASBIDataV2 read-and-subscribe path; remaining ops (Write/PublishWriteComplete/DeleteMonitoredItems) are mechanical extensions of the same pattern. Contracts: * `MonitoredItemValue` codec (IAsbCustomSerializableType binary fast-path: ItemIdentity + RuntimeValue + AsbVariant per `AsbContracts.cs:1064-1068`) with array codec (4-byte int32 count + per-element body, mirrors `WriteArrayToStream` at `cs:1095-1103`). Request builders: * `build_create_subscription_request_body(max_queue_size, sample_interval)` — primitive fields per `cs:215-223`. * `build_delete_subscription_request_body(subscription_id)` — primitive field per `cs:232-237`. * `build_publish_request_body(subscription_id)` — primitive field per `cs:287-292`. * `build_add_monitored_items_request_body(subscription_id, items, require_id)` — minimal MonitoredItem shape (Item + SampleInterval + Buffered). Full optional-field set (Active/TimeDeadband/ValueDeadband/UserData) deferred to a later iteration once a live capture confirms the WCF DataContract XML wire form. Response decoders: * `decode_create_subscription_response` — single int64 SubscriptionId field. Decoder accepts Int64Text, Int32Text, Zero/One, or numeric-string Chars (covers all WCF binary numeric encodings). * `decode_add_monitored_items_response` — Status array + ItemCapabilities-presence flag (mirrors RegisterItemsResponse). * `decode_publish_response` — Status array + Values (MonitoredItemValue) array. `BodyField::Int64Element` variant added for the primitive SubscriptionId / MaxQueueSize / SampleInterval fields. `uint64` helper casts to i64 (covers proven value range; if ulong > i64::MAX ever appears we'll add UInt64Text to F21's NbfxText enum). Client wrappers (4 new methods on AsbClient): * `create_subscription(max_queue_size, sample_interval)` * `add_monitored_items(subscription_id, items, require_id)` * `publish(subscription_id)` * `delete_subscription(subscription_id)` 11 new tests cover: * MonitoredItemValue round-trip + array round-trip. * CreateSubscription request body shape (Int64 payloads). * CreateSubscription response decoder via Int64Text. * CreateSubscription response decoder via Chars text fallback. * CreateSubscription response missing-field error. * AddMonitoredItems body carries SubscriptionId + MonitoredItem elements. * AddMonitoredItems response Status round-trip. * DeleteSubscription body carries SubscriptionId. * Publish request body shape. * Publish response Status + Values round-trip. Workspace: 691 tests pass (was 680, +11). The asb-subscribe example can now do create_subscription → add_monitored_items → publish-loop → delete_subscription once wire-byte reconciliation against a live capture confirms the MonitoredItem XML shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
650 lines
21 KiB
Rust
650 lines
21 KiB
Rust
//! `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 `<ASBIData>` 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<String>,
|
|
pub context_name: Option<String>,
|
|
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<String>) -> 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<u8>) {
|
|
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<u8> {
|
|
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<u8>) {
|
|
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<u8> {
|
|
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<Vec<ItemStatus>, 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<u8> {
|
|
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<u8>) {
|
|
self.item.encode_into(out);
|
|
self.value.encode_into(out);
|
|
self.user_data.encode_into(out);
|
|
}
|
|
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
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<u8> {
|
|
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<Vec<MonitoredItemValue>, 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<u8> {
|
|
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<Vec<ItemIdentity>, 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<u8>, 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<Option<String>, 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<u16, CodecError> {
|
|
let bytes = read_array::<2>(input, cursor)?;
|
|
Ok(u16::from_le_bytes(bytes))
|
|
}
|
|
|
|
fn read_u32_le(input: &[u8], cursor: &mut usize) -> Result<u32, CodecError> {
|
|
let bytes = read_array::<4>(input, cursor)?;
|
|
Ok(u32::from_le_bytes(bytes))
|
|
}
|
|
|
|
fn read_i32_le(input: &[u8], cursor: &mut usize) -> Result<i32, CodecError> {
|
|
let bytes = read_array::<4>(input, cursor)?;
|
|
Ok(i32::from_le_bytes(bytes))
|
|
}
|
|
|
|
fn read_u64_le(input: &[u8], cursor: &mut usize) -> Result<u64, CodecError> {
|
|
let bytes = read_array::<8>(input, cursor)?;
|
|
Ok(u64::from_le_bytes(bytes))
|
|
}
|
|
|
|
fn read_u8(input: &[u8], cursor: &mut usize) -> Result<u8, CodecError> {
|
|
let byte = *input.get(*cursor).ok_or(CodecError::ShortRead {
|
|
expected: 1,
|
|
actual: 0,
|
|
})?;
|
|
*cursor += 1;
|
|
Ok(byte)
|
|
}
|
|
|
|
fn read_array<const N: usize>(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::<ItemIdentity>::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]);
|
|
}
|
|
}
|