Skip to main content

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}