1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
|
//! Local service state kept next to the Matter fabric storage.
//!
//! Two small JSON files, both written atomically (temp file + rename) so a
//! power loss mid-write never corrupts them:
//!
//! - `service-state.json`: per-node sync results, file-compatible with the
//! TypeScript implementation.
//! - `nodes.json`: the node registry (id plus cached vendor/product names),
//! written at commissioning time so `nodes` and `status` never need to
//! start the Matter stack.
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use jiff::Timestamp;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NodeState {
pub last_successful_connection: Option<Timestamp>,
pub last_successful_sync: Option<Timestamp>,
pub last_attempted_sync: Option<Timestamp>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ServiceState {
/// Keyed by node ID as a decimal string, like the TypeScript state file.
pub nodes: BTreeMap<String, NodeState>,
}
/// Cached identity of a commissioned node, captured at commissioning.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NodeInfo {
pub vendor_name: Option<String>,
pub product_name: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct NodeRegistry {
pub nodes: BTreeMap<String, NodeInfo>,
}
impl NodeRegistry {
pub fn node_ids(&self) -> impl Iterator<Item = u64> + '_ {
// Registry keys are written by us; a non-decimal key would be file
// corruption, which the lenient loader already reduces to "empty".
self.nodes.keys().filter_map(|key| key.parse().ok())
}
}
pub fn service_state_path(storage: &Path) -> PathBuf {
storage.join("service-state.json")
}
pub fn registry_path(storage: &Path) -> PathBuf {
storage.join("nodes.json")
}
/// Reads a state file leniently: missing or corrupt files yield the default.
/// These files hold status metadata only; refusing to run over them would
/// turn a scratched cache into an outage.
pub fn load_or_default<T: DeserializeOwned + Default>(path: &Path) -> T {
match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(),
Err(_) => T::default(),
}
}
/// Atomic JSON write: temp file in the same directory, then rename.
pub fn store<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let tmp = path.with_extension("json.tmp");
let body = serde_json::to_string_pretty(value).expect("state serialization cannot fail");
write_private(&tmp, body.as_bytes())?;
std::fs::rename(&tmp, path)
}
#[cfg(unix)]
pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
file.write_all(b"\n")?;
// Flush to disk before the caller renames over the real file: the
// atomicity claim is only true if the temp file's bytes are durable,
// otherwise a power loss can surface the rename with empty content and
// orphan the root CA key. A one-shot CLI does not care about the cost.
file.sync_all()
}
#[cfg(not(unix))]
pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
std::fs::write(path, [bytes, b"\n"].concat())
}
/// Merges a patch into one node's entry and persists the result.
pub fn update_node_state(
path: &Path,
node_id: u64,
patch: impl FnOnce(&mut NodeState),
) -> io::Result<()> {
let mut state: ServiceState = load_or_default(path);
patch(state.nodes.entry(node_id.to_string()).or_default());
store(path, &state)
}
/// Drops one node's entry (after decommissioning) and persists the result.
pub fn remove_node_state(path: &Path, node_id: u64) -> io::Result<()> {
let mut state: ServiceState = load_or_default(path);
state.nodes.remove(&node_id.to_string());
store(path, &state)
}
#[cfg(test)]
mod tests {
use super::*;
fn tempdir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("mts-state-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn state_round_trips_and_merges() {
let path = tempdir().join("service-state.json");
update_node_state(&path, 1, |node| {
node.last_error = Some("boom".into());
})
.unwrap();
update_node_state(&path, 1, |node| {
node.last_error = None;
node.last_successful_sync = Some("2026-07-27T02:20:24.240Z".parse().unwrap());
})
.unwrap();
let state: ServiceState = load_or_default(&path);
let node = &state.nodes["1"];
assert_eq!(node.last_error, None);
assert!(node.last_successful_sync.is_some());
remove_node_state(&path, 1).unwrap();
let state: ServiceState = load_or_default(&path);
assert!(state.nodes.is_empty());
}
#[test]
fn reads_typescript_state_files() {
// Exact shape written by the TypeScript implementation.
let raw = r#"{
"nodes": {
"1": {
"lastSuccessfulConnection": "2026-07-27T02:20:23.357Z",
"lastSuccessfulSync": "2026-07-27T02:20:24.240Z",
"lastAttemptedSync": "2026-07-27T02:20:22.437Z",
"lastError": null
}
}
}"#;
let state: ServiceState = serde_json::from_str(raw).unwrap();
assert_eq!(state.nodes["1"].last_error, None);
assert!(state.nodes["1"].last_successful_sync.is_some());
}
#[test]
fn corrupt_files_reduce_to_defaults() {
let path = tempdir().join("corrupt.json");
std::fs::write(&path, "{ not json").unwrap();
let state: ServiceState = load_or_default(&path);
assert!(state.nodes.is_empty());
}
#[test]
fn registry_lists_node_ids() {
let mut registry = NodeRegistry::default();
registry.nodes.insert(
"1".into(),
NodeInfo {
vendor_name: Some("IKEA of Sweden".into()),
product_name: Some("ALPSTUGA air quality monitor".into()),
},
);
assert_eq!(registry.node_ids().collect::<Vec<_>>(), vec![1]);
}
}
|