src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-07-28 18:09:57 -0500
committerLuke Hoersten <[email protected]>2026-07-28 18:09:57 -0500
commitcf5eabb9647e6d53ec29a3f7de599a38dbfd5490 (patch)
tree89fcd6f00437c168f042760cc605277a957efc16
parent4129dcaeceab060d9f1cac599e9e29cab0f44fd7 (diff)
Merge the two device state files and reduce mutation
The registry (device names) and service-state (sync results) were two JSON files keyed the same way, split only for a TypeScript-compatibility that is moot now that the fabric identity cannot migrate between implementations. Merge them into one devices.json with a DeviceRecord per node, which also removes the parallel-map join in status and collapses the per-command load/store pairs. identity.json stays separate as a write-once secret. Also, from a reduction/immutability review: - Fix with_timeout passing a literal "{what}" instead of the error context - Drop needless mut: CompactDuration and parse_wall_clock become immutable expressions; run_status filters with a predicate instead of a mutator; sync_one extracts write_zone_and_dst; InspectOutcome gains Default plus a failed() constructor - Share a join_ids helper and a devices_path helper; add IdentityReport From; minor combinator tidy-ups 35 unit tests, clippy clean.
-rw-r--r--README.md7
-rw-r--r--src/controller/mod.rs17
-rw-r--r--src/controller/ops.rs218
-rw-r--r--src/main.rs64
-rw-r--r--src/state.rs142
-rw-r--r--src/time.rs41
6 files changed, 226 insertions, 263 deletions
diff --git a/README.md b/README.md
index 5037d4c..0b33e60 100644
--- a/README.md
+++ b/README.md
@@ -51,8 +51,9 @@ The same principles as the TypeScript implementation this replaces, plus the Rus
connects retry three times with backoff, since an mDNS resolve answer can be lost per-attempt.
- **Flat files, no database.** All state is plain files under `storagePath`: rs-matter's KV blobs
(`matter/k_XXXX`, written atomically via temp+rename), `identity.json` (the root CA private key;
- mode 0600), `nodes.json` (device registry), and `service-state.json` (per-node sync results). The
- config and `service-state.json` are file-compatible with the TypeScript implementation.
+ mode 0600, written once and never touched by a sync), and `devices.json` (one record per
+ commissioned device: cached names plus latest sync results). The config is compatible with the
+ TypeScript implementation.
- **Time is jiff end to end.** Timestamps are `jiff::Timestamp`; Matter-epoch microseconds exist only
at the wire boundary. DST transitions come directly from the IANA tzdb transition table, never from
offset probing. Time zones are IANA names, never fixed offsets.
@@ -170,7 +171,7 @@ Then `sudo systemctl enable --now mattertimesync.timer` (2 minutes after boot, h
## Migrating from the TypeScript implementation
-The config file and `service-state.json` carry over unchanged, but the **Matter fabric identity does
+The config file carries over unchanged, but the **Matter fabric identity does
not**: matter.js and rs-matter persist fabrics in different formats, and the device is paired to a
key, not a storage path. Migration is one re-pairing:
diff --git a/src/controller/mod.rs b/src/controller/mod.rs
index c85efdd..739615c 100644
--- a/src/controller/mod.rs
+++ b/src/controller/mod.rs
@@ -100,7 +100,8 @@ pub enum IdentityStatus {
/// Inspects the identity pair on disk. A fabric blob is any k_* file in the
/// matter/ KV directory.
pub fn identity_status(storage: &Path) -> IdentityStatus {
- let identity_exists = identity_path(storage).exists();
+ let identity_file = identity_path(storage);
+ let identity_exists = identity_file.exists();
let fabric_exists = std::fs::read_dir(matter_kv_path(storage))
.map(|entries| {
entries.flatten().any(|e| {
@@ -119,7 +120,7 @@ pub fn identity_status(storage: &Path) -> IdentityStatus {
(false, true) => {
IdentityStatus::Inconsistent("fabric storage exists but identity.json is missing")
}
- (true, true) => match std::fs::read_to_string(identity_path(storage))
+ (true, true) => match std::fs::read_to_string(&identity_file)
.ok()
.and_then(|raw| serde_json::from_str::<Identity>(&raw).ok())
{
@@ -349,8 +350,8 @@ fn ensure_fabric<C: Crypto>(
let identity_file = identity_path(&config.storage_path);
let existing = matter.with_state(|state| state.fabrics.iter().map(|f| f.fab_idx()).next());
- let registry: crate::state::NodeRegistry =
- state::load_or_default(&state::registry_path(&config.storage_path));
+ let registry: crate::state::Devices =
+ state::load_or_default(&state::devices_path(&config.storage_path));
if let Some(fab_idx) = existing {
match std::fs::read_to_string(&identity_file) {
@@ -602,13 +603,7 @@ fn prepare_storage_dir(path: &Path) -> anyhow::Result<()> {
/// so is safe to claim and tighten.
#[cfg(unix)]
fn is_our_storage_dir(path: &Path) -> bool {
- const MARKERS: [&str; 5] = [
- "identity.json",
- "matter",
- ".lock",
- "nodes.json",
- "service-state.json",
- ];
+ const MARKERS: [&str; 4] = ["identity.json", "matter", ".lock", "devices.json"];
if MARKERS.iter().any(|m| path.join(m).exists()) {
return true;
}
diff --git a/src/controller/ops.rs b/src/controller/ops.rs
index 7693650..7db1431 100644
--- a/src/controller/ops.rs
+++ b/src/controller/ops.rs
@@ -17,7 +17,11 @@ use rs_matter::transport::exchange::Exchange;
use jiff::Timestamp;
use crate::pairing::Onboarding;
-use crate::state::{NodeInfo, NodeRegistry};
+
+/// The device-registry file path for this run's storage.
+fn devices_path<C: Crypto>(ctx: &Ctx<'_, C>) -> std::path::PathBuf {
+ state::devices_path(&ctx.config.storage_path)
+}
const BROWSE_TIMEOUT_MS: u32 = 30_000;
/// Per-phase bound for commissioning (PASE handshake + invokes, CASE + complete).
@@ -38,7 +42,7 @@ async fn with_timeout<T>(
embassy_time::Duration::from_secs(secs)
));
match embassy_futures::select::select(&mut fut, &mut timer).await {
- embassy_futures::select::Either::First(result) => result.ctx("{what}"),
+ embassy_futures::select::Either::First(result) => result.ctx(what),
embassy_futures::select::Either::Second(()) => bail!("{what} timed out after {secs}s"),
}
}
@@ -124,30 +128,18 @@ impl Op for CommissionOp {
)
.await?;
- // Cache the device identity for the local registry; best-effort.
+ // Record the device: cached names plus the initial connection.
let (vendor_name, product_name) = read_device_names(ctx, device_node_id).await;
-
- let registry_file = state::registry_path(&ctx.config.storage_path);
- let mut registry: NodeRegistry = state::load_or_default(&registry_file);
- registry.nodes.insert(
- device_node_id.to_string(),
- NodeInfo {
- vendor_name: vendor_name.clone(),
- product_name: product_name.clone(),
- },
- );
- state::store(&registry_file, &registry)?;
+ state::update_device(&devices_path(ctx), device_node_id, |device| {
+ device.vendor_name = vendor_name.clone();
+ device.product_name = product_name.clone();
+ device.last_successful_connection = Some(Timestamp::now());
+ })?;
let mut identity = ctx.identity.clone();
identity.next_device_node_id += 1;
state::store(&identity_path(&ctx.config.storage_path), &identity)?;
- state::update_node_state(
- &state::service_state_path(&ctx.config.storage_path),
- device_node_id,
- |node| node.last_successful_connection = Some(Timestamp::now()),
- )?;
-
Ok(CommissionOutcome {
node_id: device_node_id.into(),
fabric_id: ctx.identity.fabric_id.into(),
@@ -314,23 +306,23 @@ impl Op for SyncOp {
type Out = Vec<SyncOutcome>;
async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Vec<SyncOutcome>> {
- let state_path = state::service_state_path(&ctx.config.storage_path);
+ let state_path = devices_path(ctx);
let mut outcomes = Vec::with_capacity(self.targets.len());
for node_id in self.targets {
- state::update_node_state(&state_path, node_id, |n| {
- n.last_attempted_sync = Some(Timestamp::now());
+ state::update_device(&state_path, node_id, |d| {
+ d.last_attempted_sync = Some(Timestamp::now());
})?;
let outcome = match sync_one(ctx, node_id, self.manual_time).await {
Ok(outcome) => outcome,
Err(error) => SyncOutcome::failed(node_id, format!("{error:#}")),
};
- state::update_node_state(&state_path, node_id, |n| {
+ state::update_device(&state_path, node_id, |d| {
if outcome.success {
- n.last_successful_sync = Some(Timestamp::now());
- n.last_successful_connection = Some(Timestamp::now());
- n.last_error = None;
+ d.last_successful_sync = Some(Timestamp::now());
+ d.last_successful_connection = Some(Timestamp::now());
+ d.last_error = None;
} else {
- n.last_error = outcome.error.clone();
+ d.last_error = outcome.error.clone();
}
})?;
if let Some(error) = &outcome.error {
@@ -398,67 +390,11 @@ async fn sync_one<C: Crypto>(
.ctx("SetUTCTime rejected")?;
log::info!("Node {node_id}: SetUTCTime {utc_write}");
- let mut time_zone_written = None;
- let mut dst_entries_written = 0;
- if has_time_zone {
- let zone_entries = build_time_zone_list(&tz, &ctx.config.timezone, now_ts);
- let response = connect(ctx, node_id)
- .await?
- .time_synchronization()
- .set_time_zone(ROOT_ENDPOINT_ID, |b| {
- let mut list = b.time_zone()?;
- for entry in &zone_entries {
- list = list
- .push()?
- .offset(entry.offset_seconds)?
- .valid_at(entry.valid_at.0)?
- .name(Some(&entry.name))?
- .end()?;
- }
- list.end()?.end()
- })
- .await
- .ctx("SetTimeZone rejected")?;
- let dst_required = response
- .response()
- .map(|r| r.dst_offset_required().unwrap_or(true))
- .unwrap_or(true);
- response.complete().await.ctx("SetTimeZone completion")?;
- time_zone_written = Some(ctx.config.timezone.clone());
- log::info!("Node {node_id}: SetTimeZone {}", ctx.config.timezone);
-
- if dst_required {
- let max_entries = connect(ctx, node_id)
- .await?
- .time_synchronization()
- .dst_offset_list_max_size_read(ROOT_ENDPOINT_ID)
- .await
- .ctx("read dstOffsetListMaxSize")?;
- let dst_entries = build_dst_offset_list(&tz, usize::from(max_entries), now_ts);
- connect(ctx, node_id)
- .await?
- .time_synchronization()
- .set_dst_offset(ROOT_ENDPOINT_ID, |b| {
- let mut list = b.dst_offset()?;
- for entry in &dst_entries {
- list = list
- .push()?
- .offset(entry.offset_seconds)?
- .valid_starting(entry.valid_starting.0)?
- .valid_until(match entry.valid_until {
- Some(until) => rs_matter::tlv::Nullable::some(until.0),
- None => rs_matter::tlv::Nullable::none(),
- })?
- .end()?;
- }
- list.end()?.end()
- })
- .await
- .ctx("SetDSTOffset rejected")?;
- dst_entries_written = dst_entries.len();
- log::info!("Node {node_id}: SetDSTOffset with {dst_entries_written} entries");
- }
- }
+ let (time_zone_written, dst_entries_written) = if has_time_zone {
+ write_zone_and_dst(ctx, node_id, &tz, now_ts).await?
+ } else {
+ (None, 0)
+ };
// Verify by reading the clock back.
let after = connect(ctx, node_id)
@@ -495,6 +431,78 @@ async fn sync_one<C: Crypto>(
})
}
+/// Writes SetTimeZone and (when the device still needs it) SetDSTOffset,
+/// returning the zone name written and the number of DST entries. Split out
+/// of `sync_one` so the two writes read as one cohesive step.
+async fn write_zone_and_dst<C: Crypto>(
+ ctx: &Ctx<'_, C>,
+ node_id: u64,
+ tz: &jiff::tz::TimeZone,
+ now_ts: Timestamp,
+) -> anyhow::Result<(Option<String>, usize)> {
+ let zone_entries = build_time_zone_list(tz, &ctx.config.timezone, now_ts);
+ let response = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .set_time_zone(ROOT_ENDPOINT_ID, |b| {
+ let mut list = b.time_zone()?;
+ for entry in &zone_entries {
+ list = list
+ .push()?
+ .offset(entry.offset_seconds)?
+ .valid_at(entry.valid_at.0)?
+ .name(Some(&entry.name))?
+ .end()?;
+ }
+ list.end()?.end()
+ })
+ .await
+ .ctx("SetTimeZone rejected")?;
+ let dst_required = response
+ .response()
+ .map(|r| r.dst_offset_required().unwrap_or(true))
+ .unwrap_or(true);
+ response.complete().await.ctx("SetTimeZone completion")?;
+ log::info!("Node {node_id}: SetTimeZone {}", ctx.config.timezone);
+
+ if !dst_required {
+ return Ok((Some(ctx.config.timezone.clone()), 0));
+ }
+
+ let max_entries = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .dst_offset_list_max_size_read(ROOT_ENDPOINT_ID)
+ .await
+ .ctx("read dstOffsetListMaxSize")?;
+ let dst_entries = build_dst_offset_list(tz, usize::from(max_entries), now_ts);
+ connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .set_dst_offset(ROOT_ENDPOINT_ID, |b| {
+ let mut list = b.dst_offset()?;
+ for entry in &dst_entries {
+ list = list
+ .push()?
+ .offset(entry.offset_seconds)?
+ .valid_starting(entry.valid_starting.0)?
+ .valid_until(match entry.valid_until {
+ Some(until) => rs_matter::tlv::Nullable::some(until.0),
+ None => rs_matter::tlv::Nullable::none(),
+ })?
+ .end()?;
+ }
+ list.end()?.end()
+ })
+ .await
+ .ctx("SetDSTOffset rejected")?;
+ log::info!(
+ "Node {node_id}: SetDSTOffset with {} entries",
+ dst_entries.len()
+ );
+ Ok((Some(ctx.config.timezone.clone()), dst_entries.len()))
+}
+
/// Pushes the configured fabric label to the device when it differs from the
/// stored one. Best-effort: a cosmetic label must not fail a clock sync.
/// Labels are unique per device, so a conflict with another admin's label is
@@ -577,14 +585,7 @@ impl Op for DecommissionOp {
let result = decommission_one(ctx, node_id).await;
match result {
Ok(()) => {
- let registry_file = state::registry_path(&ctx.config.storage_path);
- let mut registry: NodeRegistry = state::load_or_default(&registry_file);
- registry.nodes.remove(&node_id.to_string());
- state::store(&registry_file, &registry)?;
- state::remove_node_state(
- &state::service_state_path(&ctx.config.storage_path),
- node_id,
- )?;
+ state::remove_device(&devices_path(ctx), node_id)?;
outcomes.push(DecommissionOutcome {
node_id: node_id.into(),
success: true,
@@ -633,7 +634,7 @@ pub struct InspectOp {
pub targets: Vec<u64>,
}
-#[derive(Debug, serde::Serialize)]
+#[derive(Debug, Default, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InspectOutcome {
pub node_id: crate::output::Id64,
@@ -645,6 +646,17 @@ pub struct InspectOutcome {
pub our_fabric_index: Option<u8>,
}
+impl InspectOutcome {
+ /// A node whose live inspection could not complete.
+ fn failed(node_id: u64, error: String) -> Self {
+ Self {
+ node_id: node_id.into(),
+ error: Some(error),
+ ..Default::default()
+ }
+ }
+}
+
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeSyncCaps {
@@ -665,15 +677,7 @@ impl Op for InspectOp {
Ok(outcome) => outcomes.push(outcome),
Err(error) => {
log::error!("Node {node_id}: inspect failed: {error:#}");
- outcomes.push(InspectOutcome {
- node_id: node_id.into(),
- error: Some(format!("{error:#}")),
- vendor_name: None,
- product_name: None,
- time_sync: None,
- our_fabric_label: None,
- our_fabric_index: None,
- });
+ outcomes.push(InspectOutcome::failed(node_id, format!("{error:#}")));
}
}
}
diff --git a/src/main.rs b/src/main.rs
index 16fe2df..f372f8e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -49,7 +49,7 @@ use jiff::Timestamp;
use crate::config::{Config, DEFAULT_CONFIG_PATH};
use crate::output::{ConfigReport, DstTransition, NodeListing, Output, StatusReport, SyncReport};
-use crate::state::{NodeRegistry, ServiceState};
+use crate::state::Devices;
use crate::time::MatterMicros;
#[derive(Parser)]
@@ -182,11 +182,10 @@ fn main() -> ExitCode {
struct Filter(Option<u64>);
impl Filter {
- fn retain<V>(&self, map: &mut std::collections::BTreeMap<String, V>) {
- if let Some(node) = self.0 {
- let key = node.to_string();
- map.retain(|k, _| *k == key);
- }
+ /// Whether a device (keyed by decimal node ID) passes the filter: all
+ /// devices when no `--node` was given, otherwise just the named one.
+ fn keeps(&self, key: &str) -> bool {
+ self.0.is_none_or(|node| key.parse() == Ok(node))
}
}
@@ -208,9 +207,8 @@ impl Target {
/// Targets for read-only interrogation: an empty registry is a valid,
/// empty answer. An explicitly requested unknown node is still an error.
fn resolve_or_empty(&self, config: &Config) -> anyhow::Result<Vec<u64>> {
- let registry: NodeRegistry =
- state::load_or_default(&state::registry_path(&config.storage_path));
- let known: Vec<u64> = registry.node_ids().collect();
+ let devices: Devices = state::load_or_default(&state::devices_path(&config.storage_path));
+ let known: Vec<u64> = devices.node_ids().collect();
match self.0 {
None => Ok(known),
Some(node) if known.contains(&node) => Ok(vec![node]),
@@ -219,41 +217,42 @@ impl Target {
if known.is_empty() {
"none".into()
} else {
- known
- .iter()
- .map(u64::to_string)
- .collect::<Vec<_>>()
- .join(", ")
+ join_ids(&known)
}
),
}
}
}
+/// Comma-joins a slice of displayable values (node IDs) for a message.
+fn join_ids<T: std::fmt::Display>(items: &[T]) -> String {
+ items
+ .iter()
+ .map(T::to_string)
+ .collect::<Vec<_>>()
+ .join(", ")
+}
+
fn run_status(config: &Config, filter: Filter) -> anyhow::Result<Output> {
let tz = config.time_zone();
let now = Timestamp::now();
- let state: ServiceState =
- state::load_or_default(&state::service_state_path(&config.storage_path));
- let mut registry: NodeRegistry =
- state::load_or_default(&state::registry_path(&config.storage_path));
- filter.retain(&mut registry.nodes);
- let nodes: Vec<NodeListing> = registry
+ let devices: Devices = state::load_or_default(&state::devices_path(&config.storage_path));
+ let nodes: Vec<NodeListing> = devices
.nodes
.into_iter()
- .filter_map(|(key, info)| {
- // Registry keys are decimal node IDs written by us; anything else
- // is file corruption and is skipped, matching the lenient loader.
+ .filter(|(key, _)| filter.keeps(key))
+ .filter_map(|(key, device)| {
+ // Keys are decimal node IDs written by us; anything else is file
+ // corruption and is skipped, matching the lenient loader.
let node_id: u64 = key.parse().ok()?;
- let node = state.nodes.get(&key).cloned().unwrap_or_default();
Some(NodeListing {
node_id: node_id.into(),
- vendor_name: info.vendor_name,
- product_name: info.product_name,
- last_successful_connection: node.last_successful_connection,
- last_successful_sync: node.last_successful_sync,
- last_attempted_sync: node.last_attempted_sync,
- last_error: node.last_error,
+ vendor_name: device.vendor_name,
+ product_name: device.product_name,
+ last_successful_connection: device.last_successful_connection,
+ last_successful_sync: device.last_successful_sync,
+ last_attempted_sync: device.last_attempted_sync,
+ last_error: device.last_error,
})
})
.collect();
@@ -351,10 +350,9 @@ fn run_commission(config: &Config, code: &str) -> anyhow::Result<Output> {
fn run_decommission(config: &Config, target: Target) -> anyhow::Result<Output> {
let targets = target.resolve(config)?;
let nodes = controller::run(config, controller::DecommissionOp { targets })?;
- let registry: NodeRegistry =
- state::load_or_default(&state::registry_path(&config.storage_path));
+ let devices: Devices = state::load_or_default(&state::devices_path(&config.storage_path));
Ok(Output::Decommission {
nodes,
- remaining_nodes: registry.nodes.into_keys().collect(),
+ remaining_nodes: devices.nodes.into_keys().collect(),
})
}
diff --git a/src/state.rs b/src/state.rs
index 8f30e2c..c1ca251 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -1,13 +1,11 @@
-//! Local service state kept next to the Matter fabric storage.
+//! Local device 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.
+//! One JSON file, `devices.json`, holds a record per commissioned device:
+//! its cached identity (captured at commissioning, so `status` never has to
+//! start the Matter stack) merged with its latest sync results. Written
+//! atomically (temp file + rename) so a power loss mid-write never corrupts
+//! it. The secret (the root CA key in `identity.json`) is a separate file so
+//! these frequent writes never touch it.
use std::collections::BTreeMap;
use std::io;
@@ -17,50 +15,36 @@ use jiff::Timestamp;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
+/// Everything known locally about one commissioned device.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
-pub struct NodeState {
+pub struct DeviceRecord {
+ pub vendor_name: Option<String>,
+ pub product_name: Option<String>,
pub last_successful_connection: Option<Timestamp>,
pub last_successful_sync: Option<Timestamp>,
pub last_attempted_sync: Option<Timestamp>,
pub last_error: Option<String>,
}
+/// The device registry: one record per node, keyed by node ID as a decimal
+/// 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>,
+pub struct Devices {
+ pub nodes: BTreeMap<String, DeviceRecord>,
}
-impl NodeRegistry {
+impl Devices {
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".
+ // 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")
+pub fn devices_path(storage: &Path) -> PathBuf {
+ storage.join("devices.json")
}
/// Reads a state file leniently: missing or corrupt files yield the default.
@@ -107,22 +91,22 @@ 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(
+/// Merges a patch into one device's record and persists the result.
+pub fn update_device(
path: &Path,
node_id: u64,
- patch: impl FnOnce(&mut NodeState),
+ patch: impl FnOnce(&mut DeviceRecord),
) -> io::Result<()> {
- let mut state: ServiceState = load_or_default(path);
- patch(state.nodes.entry(node_id.to_string()).or_default());
- store(path, &state)
+ let mut devices: Devices = load_or_default(path);
+ patch(devices.nodes.entry(node_id.to_string()).or_default());
+ store(path, &devices)
}
-/// 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)
+/// Drops one device's record (after decommissioning) and persists the result.
+pub fn remove_device(path: &Path, node_id: u64) -> io::Result<()> {
+ let mut devices: Devices = load_or_default(path);
+ devices.nodes.remove(&node_id.to_string());
+ store(path, &devices)
}
#[cfg(test)]
@@ -136,64 +120,50 @@ mod tests {
}
#[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());
+ fn record_round_trips_and_merges() {
+ let path = tempdir().join("devices.json");
+ update_device(&path, 1, |device| {
+ device.vendor_name = Some("IKEA of Sweden".into());
+ device.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());
+ // A later patch merges into the same record, not replacing it.
+ update_device(&path, 1, |device| {
+ device.last_error = None;
+ device.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());
- }
+ let devices: Devices = load_or_default(&path);
+ let device = &devices.nodes["1"];
+ assert_eq!(device.vendor_name.as_deref(), Some("IKEA of Sweden"));
+ assert_eq!(device.last_error, None);
+ assert!(device.last_successful_sync.is_some());
- #[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());
+ remove_device(&path, 1).unwrap();
+ let devices: Devices = load_or_default(&path);
+ assert!(devices.nodes.is_empty());
}
#[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());
+ let devices: Devices = load_or_default(&path);
+ assert!(devices.nodes.is_empty());
}
#[test]
- fn registry_lists_node_ids() {
- let mut registry = NodeRegistry::default();
- registry.nodes.insert(
+ fn lists_node_ids() {
+ let mut devices = Devices::default();
+ devices.nodes.insert(
"1".into(),
- NodeInfo {
+ DeviceRecord {
vendor_name: Some("IKEA of Sweden".into()),
product_name: Some("ALPSTUGA air quality monitor".into()),
+ ..Default::default()
},
);
- assert_eq!(registry.node_ids().collect::<Vec<_>>(), vec![1]);
+ assert_eq!(devices.node_ids().collect::<Vec<_>>(), vec![1]);
}
}
diff --git a/src/time.rs b/src/time.rs
index 6c38cc6..6616194 100644
--- a/src/time.rs
+++ b/src/time.rs
@@ -188,19 +188,16 @@ impl fmt::Display for CompactDuration {
let hours = (total_seconds % 86_400) / 3_600;
let minutes = (total_seconds % 3_600) / 60;
let seconds = total_seconds % 60;
- let mut parts: Vec<String> = Vec::new();
- if days > 0 {
- parts.push(format!("{days}d"));
- }
- if hours > 0 {
- parts.push(format!("{hours}h"));
- }
- if minutes > 0 {
- parts.push(format!("{minutes}m"));
- }
- if seconds > 0 && days == 0 {
- parts.push(format!("{seconds}s"));
- }
+ let parts: Vec<String> = [
+ (days > 0).then(|| format!("{days}d")),
+ (hours > 0).then(|| format!("{hours}h")),
+ (minutes > 0).then(|| format!("{minutes}m")),
+ // Seconds are dropped once days appear (see the doc comment).
+ (seconds > 0 && days == 0).then(|| format!("{seconds}s")),
+ ]
+ .into_iter()
+ .flatten()
+ .collect();
f.write_str(&parts.join(" "))
}
}
@@ -353,18 +350,16 @@ pub fn parse_wall_clock(input: &str) -> Result<jiff::civil::Time, String> {
.map_err(|_| format!("cannot parse {p:?} in {input:?} as a number"))
})
.collect::<Result<_, _>>()?;
- let (mut hour, minute, second) = (numbers[0], numbers[1], *numbers.get(2).unwrap_or(&0));
-
- match meridiem {
- Some(pm) => {
- if !(1..=12).contains(&hour) {
- return Err(format!("hour in {input:?} must be 1-12 with am/pm"));
- }
- hour = if pm { hour % 12 + 12 } else { hour % 12 };
+ let (hour, minute, second) = (numbers[0], numbers[1], *numbers.get(2).unwrap_or(&0));
+ let hour = match meridiem {
+ Some(_) if !(1..=12).contains(&hour) => {
+ return Err(format!("hour in {input:?} must be 1-12 with am/pm"));
}
+ Some(true) => hour % 12 + 12,
+ Some(false) => hour % 12,
None if hour > 23 => return Err(format!("hour in {input:?} must be 0-23")),
- None => {}
- }
+ None => hour,
+ };
jiff::civil::Time::new(hour as i8, minute as i8, second as i8, 0)
.map_err(|e| format!("invalid time {input:?}: {e}"))
}