sparkplug_b/state.rs
1//! Host Application STATE payload (spec §6.4.27).
2//!
3//! Unlike the Sparkplug data payloads (protobuf), STATE is JSON UTF-8 with
4//! exactly two keys: `online` (bool) and `timestamp` (epoch milliseconds UTC)
5//! (`tck-id-host-topic-phid-birth-payload`). It is published retained at QoS 1
6//! on `spBv1.0/STATE/<host_id>`; the birth (online) reuses the will (offline)
7//! timestamp (`tck-id-host-topic-phid-birth-payload-timestamp`).
8//!
9//! The format is small and fixed, so we serialize/parse it directly (no serde
10//! dependency — the foundation crate stays at `bytes` + `thiserror`).
11
12use crate::error::{Result, SparkplugError};
13
14/// A Host Application STATE message body.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct StatePayload {
17 /// Whether the Host Application is online.
18 pub online: bool,
19 /// Epoch milliseconds (UTC) of this state.
20 pub timestamp: i64,
21}
22
23impl StatePayload {
24 /// Construct a STATE payload.
25 #[must_use]
26 pub const fn new(online: bool, timestamp: i64) -> Self {
27 Self { online, timestamp }
28 }
29
30 /// Serialize to the canonical JSON form `{"online":<bool>,"timestamp":<ms>}`.
31 #[must_use]
32 pub fn to_json(&self) -> String {
33 format!(
34 "{{\"online\":{},\"timestamp\":{}}}",
35 self.online, self.timestamp
36 )
37 }
38
39 /// Parse a STATE JSON payload.
40 ///
41 /// Tolerant of whitespace and key order; requires both `online` and
42 /// `timestamp`. The STATE object has no nested structure, so this small
43 /// hand parser is sufficient and total.
44 ///
45 /// # Errors
46 /// Returns [`SparkplugError::InvalidState`] for malformed JSON, a missing
47 /// key, or an out-of-form value.
48 pub fn parse(json: &str) -> Result<Self> {
49 let trimmed = json.trim();
50 let inner = trimmed
51 .strip_prefix('{')
52 .and_then(|s| s.strip_suffix('}'))
53 .ok_or_else(|| SparkplugError::InvalidState("expected a JSON object".to_owned()))?;
54
55 let mut online: Option<bool> = None;
56 let mut timestamp: Option<i64> = None;
57
58 for field in inner.split(',') {
59 if field.trim().is_empty() {
60 continue;
61 }
62 let (key, value) = field.split_once(':').ok_or_else(|| {
63 SparkplugError::InvalidState(format!("expected key:value, got {field:?}"))
64 })?;
65 let key = key.trim().trim_matches('"');
66 let value = value.trim();
67 match key {
68 "online" => {
69 online = Some(match value {
70 "true" => true,
71 "false" => false,
72 other => {
73 return Err(SparkplugError::InvalidState(format!(
74 "online must be true/false, got {other:?}"
75 )));
76 }
77 });
78 }
79 "timestamp" => {
80 timestamp = Some(value.parse::<i64>().map_err(|e| {
81 SparkplugError::InvalidState(format!("timestamp not an integer: {e}"))
82 })?);
83 }
84 // Ignore unknown keys for forward compatibility.
85 _ => {}
86 }
87 }
88
89 match (online, timestamp) {
90 (Some(online), Some(timestamp)) => Ok(Self { online, timestamp }),
91 _ => Err(SparkplugError::InvalidState(
92 "missing 'online' or 'timestamp'".to_owned(),
93 )),
94 }
95 }
96}