src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock2091
-rw-r--r--Cargo.toml34
-rw-r--r--README.md179
-rw-r--r--examples/config.example.json6
-rw-r--r--examples/mattertimesync.service15
-rw-r--r--examples/mattertimesync.timer10
-rw-r--r--src/config.rs264
-rw-r--r--src/controller.rs1332
-rw-r--r--src/host.rs88
-rw-r--r--src/main.rs374
-rw-r--r--src/output.rs444
-rw-r--r--src/pairing.rs129
-rw-r--r--src/state.rs194
-rw-r--r--src/time.rs367
-rw-r--r--src/tz.rs268
16 files changed, 5796 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..d402e52
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2091 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link",
+]
+
+[[package]]
+name = "backtrace-ext"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
+dependencies = [
+ "backtrace",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "brownstone"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5839ee4f953e811bfdcf223f509cb2c6a3e1447959b0bff459405575bc17f22"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "bytecount"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "ccm"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847"
+dependencies = [
+ "aead",
+ "cipher",
+ "ctr",
+ "subtle",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96272c2ff28b807e09250b180ad1fb7889a3258f7455759b5c3c58b719467130"
+dependencies = [
+ "num-traits",
+ "subtle",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "der_derive",
+ "flagset",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "der_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "domain"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c13d25dfb29edf328c7eb4ebb24838ade355272d685ba0402b8751567c3dde2"
+dependencies = [
+ "domain-macros",
+ "heapless",
+ "jiff",
+ "octseq",
+]
+
+[[package]]
+name = "domain-macros"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e68f247cb4e380bcdf44c1e27d7d046cbb321c4ab4a01271a51dfb19326b6b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+]
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint 0.5.5",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "embassy-executor-timer-queue"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c"
+
+[[package]]
+name = "embassy-futures"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
+
+[[package]]
+name = "embassy-sync"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81"
+dependencies = [
+ "cfg-if",
+ "critical-section",
+ "embedded-io-async",
+ "futures-core",
+ "futures-sink",
+ "heapless",
+]
+
+[[package]]
+name = "embassy-time"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a"
+dependencies = [
+ "cfg-if",
+ "critical-section",
+ "document-features",
+ "embassy-time-driver",
+ "embassy-time-queue-utils",
+ "embedded-hal 0.2.7",
+ "embedded-hal 1.0.0",
+ "embedded-hal-async",
+ "futures-core",
+ "log",
+]
+
+[[package]]
+name = "embassy-time-driver"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9"
+dependencies = [
+ "document-features",
+]
+
+[[package]]
+name = "embassy-time-queue-utils"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "168297bf80aaf114b3c9ad589bf38b01b3009b9af7f97cd18086c5bbf96f5693"
+dependencies = [
+ "embassy-executor-timer-queue",
+ "heapless",
+]
+
+[[package]]
+name = "embedded-hal"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff"
+dependencies = [
+ "nb 0.1.3",
+ "void",
+]
+
+[[package]]
+name = "embedded-hal"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89"
+
+[[package]]
+name = "embedded-hal-async"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884"
+dependencies = [
+ "embedded-hal 1.0.0",
+]
+
+[[package]]
+name = "embedded-io"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7"
+
+[[package]]
+name = "embedded-io-async"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0"
+dependencies = [
+ "embedded-io",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "flagset"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "hash32"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heapless"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
+dependencies = [
+ "hash32",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "if-addrs"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "indent_write"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cfe9645a18782869361d9c8732246be7b410ad4e919d3609ebabdac00ba12c3"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "is_ci"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
+dependencies = [
+ "defmt",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "joinery"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72167d68f5fce3b8655487b8038691a3c9984ee769590f93f2a631f4ad64e4f5"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "mattertimesync"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-io",
+ "clap",
+ "embassy-futures",
+ "embassy-time",
+ "embassy-time-queue-utils",
+ "env_logger",
+ "futures-lite",
+ "if-addrs",
+ "jiff",
+ "libc",
+ "log",
+ "rand",
+ "rs-matter",
+ "serde",
+ "serde_json",
+ "socket2",
+ "static_cell",
+ "thiserror 2.0.19",
+ "verhoeff",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "backtrace",
+ "backtrace-ext",
+ "cfg-if",
+ "miette-derive",
+ "owo-colors",
+ "supports-color",
+ "supports-hyperlinks",
+ "supports-unicode",
+ "terminal_size",
+ "textwrap",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "nb"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f"
+dependencies = [
+ "nb 1.1.0",
+]
+
+[[package]]
+name = "nb"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d"
+
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.13.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nom-greedyerror"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f359007d505b20cd6e4974ff0d5c8e4565f0f9e15823937238221ccb74b516"
+dependencies = [
+ "nom",
+ "nom_locate",
+]
+
+[[package]]
+name = "nom-supreme"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bd3ae6c901f1959588759ff51c95d24b491ecb9ff91aa9c2ef4acc5b1dcab27"
+dependencies = [
+ "brownstone",
+ "indent_write",
+ "joinery",
+ "memchr",
+ "nom",
+]
+
+[[package]]
+name = "nom_locate"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3"
+dependencies = [
+ "bytecount",
+ "memchr",
+ "nom",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "octseq"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "182eab3e1cd9cdc0ecf1ce3342d9844f3dc7d098f0694569bfdf327b612d69fd"
+dependencies = [
+ "heapless",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "owo-colors"
+version = "4.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pinned-init"
+version = "0.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf55f7030b0b17ea0c23525b8a18d9b52a53a586ec355128ac2304aa2fa8c04"
+dependencies = [
+ "paste",
+ "pinned-init-macro",
+]
+
+[[package]]
+name = "pinned-init-macro"
+version = "0.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "569a3fcf0e6334c6c46c1507f83a99ba30ae9cdc5761bdb2ca40f7eb55138bd4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "qrcodegen-no-heap"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0e2c0bf8be8a1c4a4f48973dabf26943f05da2bfc2d3180aae62409dbba6f0c"
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "rs-matter"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e65740dac8653d785f53e8973ac3598d33e6466ece9e9df69d1b62c07c5b41a5"
+dependencies = [
+ "aes",
+ "async-io",
+ "bitflags 2.13.1",
+ "ccm",
+ "cfg-if",
+ "cipher",
+ "critical-section",
+ "crypto-bigint 0.6.1",
+ "defmt",
+ "der",
+ "digest",
+ "domain",
+ "ecdsa",
+ "either",
+ "elliptic-curve",
+ "embassy-futures",
+ "embassy-sync",
+ "embassy-time",
+ "embedded-io-async",
+ "heapless",
+ "hkdf",
+ "hmac",
+ "if-addrs",
+ "log",
+ "nix",
+ "num",
+ "num-derive",
+ "num-traits",
+ "num_enum",
+ "p256",
+ "pbkdf2",
+ "pinned-init",
+ "primeorder",
+ "qrcodegen-no-heap",
+ "rand",
+ "rand_core",
+ "rs-matter-codegen",
+ "rs-matter-macros",
+ "scopeguard",
+ "sec1",
+ "sha1",
+ "sha2",
+ "signature",
+ "strum",
+ "subtle",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "verhoeff",
+ "x509-cert",
+]
+
+[[package]]
+name = "rs-matter-codegen"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d50e0230f99b1f579e892001c0f30468cdbe1d5eb8ac3cfb69bb2171c52b9c6f"
+dependencies = [
+ "convert_case",
+ "miette",
+ "nom",
+ "nom-greedyerror",
+ "nom-supreme",
+ "nom_locate",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "thiserror 1.0.69",
+ "tracing",
+]
+
+[[package]]
+name = "rs-matter-macros"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db56aa5820960e514e9850fed999b365c678b71615d8873126f15efe14441999"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_cell"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "supports-color"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "supports-hyperlinks"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91"
+
+[[package]]
+name = "supports-unicode"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "unicode-linebreak",
+ "unicode-width 0.2.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl 2.0.19",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "time"
+version = "0.3.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "time-core",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "verhoeff"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffb16c19000204197604686ecda606fbeecb2626cfa664dd468d072d99698417"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "void"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "x509-cert"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
+dependencies = [
+ "const-oid",
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..df47edf
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,34 @@
+[package]
+name = "mattertimesync"
+version = "0.1.0"
+edition = "2024"
+description = "CLI Matter controller that synchronizes the clocks of Matter devices via the standard Time Synchronization cluster"
+license = "MIT"
+
+[dependencies]
+anyhow = "1.0.104"
+async-io = "2.6.0"
+clap = { version = "4.6.4", features = ["derive"] }
+embassy-futures = "0.1.2"
+embassy-time = { version = "0.5.1", features = ["std"] }
+embassy-time-queue-utils = { version = "0.3.2", features = ["generic-queue-64"] }
+env_logger = "0.11.11"
+futures-lite = "2.6.1"
+if-addrs = "0.15.0"
+jiff = { version = "0.2.35", features = ["serde"] }
+libc = "0.2.189"
+log = "0.4.33"
+rand = { version = "0.8", features = ["std", "std_rng"] }
+rs-matter = "0.2.0"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+socket2 = { version = "0.6.5", features = ["all"] }
+static_cell = "2.1.1"
+thiserror = "2.0.19"
+verhoeff = "1.0.0"
+
+[profile.release]
+# One Pi-sized binary; favor size and honest backtraces over compile time.
+lto = true
+codegen-units = 1
+strip = "debuginfo"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6c70450
--- /dev/null
+++ b/README.md
@@ -0,0 +1,179 @@
+# mattertimesync
+
+A small CLI Matter controller that sets the clocks of Matter devices via the standard Time
+Synchronization cluster. Rust implementation, built on [rs-matter](https://crates.io/crates/rs-matter)
+(the official CSA Rust Matter stack).
+
+Some Matter devices with clock displays (air-quality monitors, thermostats, sensor hubs) lose their
+clock after a power outage, and not every ecosystem restores it reliably. This tool joins each device's
+existing Matter setup as an _additional_ controller (Matter multi-admin) and pushes the correct time to
+every device commissioned onto its fabric. Run it periodically from a systemd timer; the primary
+ecosystem (e.g. Apple Home) keeps working unchanged.
+
+There is no daemon: each invocation builds the Matter stack, connects, syncs, and exits.
+
+## How it works
+
+- The device stays commissioned to its primary ecosystem and attached to its existing Thread or Wi-Fi
+ network.
+- A Raspberry Pi (or any Linux host) runs this tool as a second Matter administrator on its own fabric.
+- For Thread devices, the host needs no Thread radio, no OpenThread Border Router, no Thread
+ credentials, and no BLE. It reaches the device over IPv6 through the existing Thread Border Routers
+ (e.g. HomePod, Apple TV). A host on the same network segment as the border routers learns the route
+ to the Thread network automatically from their Router Advertisements; a host on a different VLAN
+ needs one static route on the gateway (see Troubleshooting).
+- The host's own clock is NTP-synchronized; the tool refuses to push time from an unsynchronized clock.
+
+## Design
+
+The same principles as the TypeScript implementation this replaces, plus the Rust-specific ones:
+
+- **One-shot CLI, not a daemon.** Being a secondary Matter controller is fabric membership, not a
+ running process. Each invocation loads the persisted fabric identity, races the Matter transport and
+ mDNS pumps against the one operation, and exits. Clock staleness is bounded by the systemd timer
+ interval.
+- **Capability-driven, never vendor-specific.** Any device exposing the standard Time Synchronization
+ cluster (0x38) works. SetTimeZone/SetDSTOffset are gated on the device's feature map and bounded by
+ its DSTOffsetListMaxSize; devices without the cluster are skipped with a warning; devices whose
+ firmware encodes Unix-epoch time on the wire are detected by the exact 946,684,800s shift and
+ reported corrected.
+- **The fabric is created exactly once.** First run mints the controller's root CA and operational
+ identity (RCAC-direct mode); every later run loads it. The identity lifecycle is guarded: runs hold
+ an exclusive lock on the storage directory, a fresh identity is only ever minted over provably
+ virgin storage (no fabric, no identity file, no registry entries), the one partial state an
+ interrupted first run can produce is discarded automatically, and any state a commissioned device
+ could depend on is refused loudly rather than overwritten. Reconnection uses Matter operational
+ discovery (mDNS), never a stored IP address.
+- **Fail safely.** Never recommissions automatically; never pushes time unless the host clock is
+ trusted, checked by directly measuring its offset against an NTP server (one SNTP query, accepted
+ under 1s; `timedatectl` as fallback when no server is reachable). Every sync is verified by reading
+ the clock back and comparing against the written instant; non-zero exit lets the timer retry. CASE
+ 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.
+- **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.
+- **Intentionally small.** No GUI, database, cloud, QR-code pairing (the primary ecosystem always
+ shows the numeric code), general Matter administration, or debugging surfaces beyond what operating
+ this tool needs.
+
+## Commands
+
+Local, read-only (no device contact):
+
+```bash
+mattertimesync status [--node <id>] [--json] # config, identity, and per-device sync state
+```
+
+Fabric interrogation (connects to the device, reads only):
+
+```bash
+mattertimesync inspect [--node <id>] [--json]
+```
+
+Fabric-writing:
+
+```bash
+mattertimesync commission <pairing-code> # writes the device's fabric table + local storage
+mattertimesync sync [--node <id>] [--time <hh:mm>] [--json] # set clock, time zone, DST offsets
+mattertimesync decommission [--node <id>] # device(s) drop this controller's fabric
+```
+
+No command writes local state alone; the registry and sync results change only as part of a
+fabric-writing command.
+
+`--node <id>` targets one device; `sync` and `decommission` target all commissioned devices by
+default. `sync --time 16:35` (or `4:35pm`) sets that wall-clock time, today in the configured zone,
+instead of the current time; the operator is then the time source and the NTP gate is not enforced. Logs go to stderr; stdout carries only command output, so `--json` is always clean JSON.
+Exit codes: 0 success, 1 runtime failure (timer retries), 2 usage/config error.
+
+## Configuration
+
+`/etc/mattertimesync/config.json` by default; override with `--config <path>`. Same file format as
+the TypeScript implementation (see `examples/config.example.json`):
+
+```json
+{
+ "storagePath": "/var/lib/mattertimesync",
+ "timezone": "America/Chicago",
+ "logLevel": "info",
+ "fabricLabel": "My Home"
+}
+```
+
+| Field | Default | Meaning |
+| ------------- | ------------------ | --------------------------------------------------------------- |
+| `storagePath` | (required) | Directory for persistent Matter fabric state. Contains secrets. |
+| `timezone` | `America/Chicago` | IANA time-zone name. Never a fixed UTC offset; DST is derived. |
+| `logLevel` | `info` | `debug`, `info`, `warn`, or `error`. |
+| `fabricLabel` | `Matter Time Sync` | Label other ecosystems show for this controller (max 32 chars). |
+
+Unknown fields are rejected loudly. Matter requires fabric labels to be unique per device and Apple
+Home labels its own entry with the home's name, so use a variant like "Lakeside Time Sync" rather
+than the home name itself; `sync` pushes label changes to each device.
+
+## Build and deploy
+
+```bash
+cargo build --release # target/release/mattertimesync, one self-contained binary
+cargo test
+```
+
+The binary must match the target's architecture; the simplest path is building on the target itself
+(a Raspberry Pi 4 handles it fine):
+
+```bash
+# on the Pi, one-time toolchain install (user-local, ~/.rustup and ~/.cargo)
+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
+git clone <repository> && cd mattertimesync
+~/.cargo/bin/cargo build --release
+```
+
+The target machine needs nothing but the binary (and a tzdb, present on any Linux). On the target:
+
+```bash
+sudo install -D -m 0755 mattertimesync /opt/mattertimesync/mattertimesync
+sudo useradd --system --home /var/lib/mattertimesync --shell /usr/sbin/nologin mattertimesync
+sudo mkdir -p /etc/mattertimesync /var/lib/mattertimesync
+sudo chown mattertimesync:mattertimesync /var/lib/mattertimesync
+sudo chmod 700 /var/lib/mattertimesync
+sudo cp examples/mattertimesync.service examples/mattertimesync.timer /etc/systemd/system/
+sudo systemctl daemon-reload
+```
+
+Commission (as the service user, within the pairing window opened in the primary ecosystem):
+
+```bash
+sudo -u mattertimesync /opt/mattertimesync/mattertimesync \
+ --config /etc/mattertimesync/config.json commission 12345678901
+```
+
+Then `sudo systemctl enable --now mattertimesync.timer` (2 minutes after boot, hourly thereafter).
+
+## Migrating from the TypeScript implementation
+
+The config file and `service-state.json` carry 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:
+
+1. Run this implementation with its own fresh `storagePath` and commission the device as an
+ additional admin (Matter allows several; the fabric table has at least 5 slots). Both
+ implementations can sync side by side while validating.
+2. Once satisfied, `decommission` with the **old** implementation so the device drops its fabric,
+ point `mattertimesync.service` at this binary, and delete the old storage directory.
+
+## Troubleshooting
+
+- **Sync refuses to run**: the host clock is not NTP-synchronized (`timedatectl status`). The tool
+ will not push time it does not trust.
+- **Commissionable device not found**: the pairing window expired, or mDNS/multicast is blocked
+ between the host and the device's network.
+- **Thread device discovered but unreachable**: the host has no route to the Thread network's
+ `fd.../64` prefix. On the border routers' own network segment the route is learned automatically
+ from their Router Advertisements; across VLANs, add one static IPv6 route on the gateway (the
+ prefix via a border router's stable address). Verify with `ping` of the device's `fd...` address.
+- **Clock off by one hour**: DST configuration missing or rejected; this tool always derives offsets
+ from the IANA database, so check the device accepted SetDSTOffset (sync logs).
diff --git a/examples/config.example.json b/examples/config.example.json
new file mode 100644
index 0000000..831630c
--- /dev/null
+++ b/examples/config.example.json
@@ -0,0 +1,6 @@
+{
+ "storagePath": "/var/lib/mattertimesync",
+ "timezone": "America/Chicago",
+ "logLevel": "info",
+ "fabricLabel": "Rust Matter Time Sync"
+}
diff --git a/examples/mattertimesync.service b/examples/mattertimesync.service
new file mode 100644
index 0000000..8fc48c1
--- /dev/null
+++ b/examples/mattertimesync.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=Matter device clock synchronization
+Wants=network-online.target
+After=network-online.target time-sync.target
+
+[Service]
+Type=oneshot
+User=mattertimesync
+Group=mattertimesync
+ExecStart=/opt/mattertimesync/mattertimesync --config /etc/mattertimesync/config.json sync
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=/var/lib/mattertimesync
diff --git a/examples/mattertimesync.timer b/examples/mattertimesync.timer
new file mode 100644
index 0000000..4d7b64a
--- /dev/null
+++ b/examples/mattertimesync.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Periodic Matter device clock synchronization
+
+[Timer]
+OnBootSec=2min
+OnUnitActiveSec=1h
+RandomizedDelaySec=5min
+
+[Install]
+WantedBy=timers.target
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..3e53975
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,264 @@
+//! Validated JSON configuration, file-compatible with the TypeScript
+//! implementation (`/etc/mattertimesync/config.json`, camelCase keys).
+//!
+//! Unknown fields are rejected loudly via serde's `deny_unknown_fields`;
+//! device membership deliberately lives in controller storage, not here.
+
+use std::fmt;
+use std::path::PathBuf;
+
+use jiff::tz::TimeZone;
+use serde::Deserialize;
+
+pub const DEFAULT_CONFIG_PATH: &str = "/etc/mattertimesync/config.json";
+
+/// Matter FabricDescriptorStruct label limit.
+const FABRIC_LABEL_MAX_LENGTH: usize = 32;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ConfigError {
+ #[error("cannot read configuration file {path}: {source}")]
+ Unreadable {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ #[error("configuration is not valid: {0}")]
+ Invalid(#[from] serde_json::Error),
+ #[error("\"timezone\" must be a valid IANA time-zone name (got {0:?})")]
+ BadTimezone(String),
+ #[error(
+ "\"fabricLabel\" must be a non-empty string of at most {FABRIC_LABEL_MAX_LENGTH} characters"
+ )]
+ BadFabricLabel,
+ #[error(
+ "\"storagePath\" must be an absolute path (got {0:?}); JSON configs get no shell expansion"
+ )]
+ RelativeStoragePath(PathBuf),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LogLevel {
+ Debug,
+ #[default]
+ Info,
+ Warn,
+ Error,
+}
+
+impl From<LogLevel> for log::LevelFilter {
+ fn from(level: LogLevel) -> Self {
+ match level {
+ LogLevel::Debug => log::LevelFilter::Debug,
+ LogLevel::Info => log::LevelFilter::Info,
+ LogLevel::Warn => log::LevelFilter::Warn,
+ LogLevel::Error => log::LevelFilter::Error,
+ }
+ }
+}
+
+impl fmt::Display for LogLevel {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let name = match self {
+ LogLevel::Debug => "debug",
+ LogLevel::Info => "info",
+ LogLevel::Warn => "warn",
+ LogLevel::Error => "error",
+ };
+ f.write_str(name)
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Config {
+ /// Where this configuration was loaded from; not a config field.
+ #[serde(skip)]
+ pub source: PathBuf,
+ /// Directory holding persistent Matter fabric state and service state.
+ /// Contains the controller's private keys; mode 0700.
+ pub storage_path: PathBuf,
+ /// IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset.
+ #[serde(default = "default_timezone")]
+ pub timezone: String,
+ #[serde(default)]
+ pub log_level: LogLevel,
+ /// Fabric label other ecosystems display for this controller (e.g. the
+ /// Apple Home Connected Services subtitle). Must be unique per device.
+ #[serde(default = "default_fabric_label")]
+ pub fabric_label: String,
+}
+
+fn default_timezone() -> String {
+ "America/Chicago".into()
+}
+
+fn default_fabric_label() -> String {
+ "Matter Time Sync".into()
+}
+
+impl Config {
+ pub fn load(path: &std::path::Path) -> Result<Self, ConfigError> {
+ let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Unreadable {
+ path: path.to_owned(),
+ source,
+ })?;
+ let mut config = Self::parse(&raw)?;
+ config.source = path.to_owned();
+ Ok(config)
+ }
+
+ pub fn parse(raw: &str) -> Result<Self, ConfigError> {
+ let config: Config = serde_json::from_str(raw)?;
+ config.validate()?;
+ Ok(config)
+ }
+
+ fn validate(&self) -> Result<(), ConfigError> {
+ // No shell expansion happens on a JSON file, so a "~/..." or relative
+ // path would silently land wherever the process happens to run.
+ if !self.storage_path.is_absolute() {
+ return Err(ConfigError::RelativeStoragePath(self.storage_path.clone()));
+ }
+ // Resolving through jiff's tzdb is the validation; a bare offset or
+ // invented name fails here rather than at 3am on a DST transition.
+ TimeZone::get(&self.timezone)
+ .map_err(|_| ConfigError::BadTimezone(self.timezone.clone()))?;
+ if self.fabric_label.trim().is_empty() || self.fabric_label.len() > FABRIC_LABEL_MAX_LENGTH
+ {
+ return Err(ConfigError::BadFabricLabel);
+ }
+ Ok(())
+ }
+
+ /// Warnings for path components that look like they expected shell
+ /// expansion: a component starting with `~` or containing `$`. Such
+ /// directories can legitimately exist, so these cannot be errors; but
+ /// far more often they mean the config was written expecting a shell to
+ /// expand it, and the data would land in a literal `~foo` directory.
+ pub fn path_warnings(&self) -> Vec<String> {
+ self.storage_path
+ .components()
+ .filter_map(|component| {
+ let text = component.as_os_str().to_string_lossy();
+ let looks_like = if text.starts_with('~') {
+ "a shell tilde"
+ } else if text.contains('$') {
+ "an unexpanded shell variable"
+ } else {
+ return None;
+ };
+ Some(format!(
+ "storagePath component {text:?} looks like {looks_like}; JSON configs get no \
+ shell expansion, so it will be used as a literal directory name"
+ ))
+ })
+ .collect()
+ }
+
+ /// The configured zone, resolved against the system tzdb.
+ pub fn time_zone(&self) -> TimeZone {
+ // Validated at load time; a tzdb that shrinks between then and now is
+ // not a scenario worth threading a Result through every caller for.
+ TimeZone::get(&self.timezone).expect("timezone was validated at config load")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const MINIMAL: &str = r#"{ "storagePath": "/var/lib/mattertimesync" }"#;
+
+ #[test]
+ fn minimal_config_applies_defaults() {
+ let config = Config::parse(MINIMAL).unwrap();
+ assert_eq!(
+ config.storage_path,
+ PathBuf::from("/var/lib/mattertimesync")
+ );
+ assert_eq!(config.timezone, "America/Chicago");
+ assert_eq!(config.log_level, LogLevel::Info);
+ assert_eq!(config.fabric_label, "Matter Time Sync");
+ }
+
+ #[test]
+ fn full_config_round_trips() {
+ let config = Config::parse(
+ r#"{
+ "storagePath": "/tmp/x",
+ "timezone": "Europe/Berlin",
+ "logLevel": "debug",
+ "fabricLabel": "Lakeside Time Sync"
+ }"#,
+ )
+ .unwrap();
+ assert_eq!(config.timezone, "Europe/Berlin");
+ assert_eq!(config.log_level, LogLevel::Debug);
+ assert_eq!(config.fabric_label, "Lakeside Time Sync");
+ }
+
+ #[test]
+ fn unknown_fields_are_rejected_loudly() {
+ // Fields from abandoned designs are rejected, not ignored.
+ for raw in [
+ r#"{ "storagePath": "/x", "nodeId": "1" }"#,
+ r#"{ "storagePath": "/x", "syncIntervalHours": 24 }"#,
+ r#"{ "storagePath": "/x", "unexpected": 1 }"#,
+ ] {
+ let error = Config::parse(raw).unwrap_err();
+ assert!(error.to_string().contains("unknown field"), "{error}");
+ }
+ }
+
+ #[test]
+ fn suspicious_path_components_warn_but_load() {
+ let config = Config::parse(r#"{ "storagePath": "/data/~backup" }"#).unwrap();
+ assert_eq!(config.path_warnings().len(), 1);
+ assert!(config.path_warnings()[0].contains("shell tilde"));
+
+ let config = Config::parse(r#"{ "storagePath": "/var/lib/$USER/mts" }"#).unwrap();
+ assert!(config.path_warnings()[0].contains("unexpanded shell variable"));
+
+ let config = Config::parse(r#"{ "storagePath": "/var/lib/mattertimesync" }"#).unwrap();
+ assert!(config.path_warnings().is_empty());
+ }
+
+ #[test]
+ fn non_absolute_storage_paths_are_rejected() {
+ for path in ["~/mts-storage", "data", "./data"] {
+ let raw = format!(r#"{{ "storagePath": {path:?} }}"#);
+ let error = Config::parse(&raw).unwrap_err();
+ assert!(
+ error.to_string().contains("absolute"),
+ "accepted {path:?}: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn storage_path_is_required() {
+ assert!(Config::parse(r#"{}"#).is_err());
+ }
+
+ #[test]
+ fn invalid_timezones_are_rejected() {
+ for tz in ["Central Time", "", "America/Springfield", "UTC-6"] {
+ let raw = format!(r#"{{ "storagePath": "/x", "timezone": {tz:?} }}"#);
+ assert!(Config::parse(&raw).is_err(), "accepted {tz:?}");
+ }
+ }
+
+ #[test]
+ fn invalid_log_levels_are_rejected() {
+ assert!(Config::parse(r#"{ "storagePath": "/x", "logLevel": "verbose" }"#).is_err());
+ }
+
+ #[test]
+ fn invalid_fabric_labels_are_rejected() {
+ for label in ["", " ", &"x".repeat(33)] {
+ let raw = format!(r#"{{ "storagePath": "/x", "fabricLabel": {label:?} }}"#);
+ assert!(Config::parse(&raw).is_err(), "accepted {label:?}");
+ }
+ }
+}
diff --git a/src/controller.rs b/src/controller.rs
new file mode 100644
index 0000000..675f58d
--- /dev/null
+++ b/src/controller.rs
@@ -0,0 +1,1332 @@
+//! The rs-matter controller: persistent fabric, one-shot stack harness, and
+//! the on-wire operations (commission, sync, decommission).
+//!
+//! Being a secondary Matter controller is fabric membership, not a running
+//! process: every command builds the stack, races the transport and mDNS
+//! pumps against the actual operation, and exits. The fabric identity
+//! persists in `<storagePath>/matter/` (rs-matter's KV blobs) plus
+//! `<storagePath>/identity.json` (the RCAC private key that signs device
+//! NOCs, which rs-matter deliberately leaves to the caller).
+
+use std::net::UdpSocket;
+use std::num::NonZeroU8;
+use std::path::Path;
+use std::pin::pin;
+
+use anyhow::{Context as _, anyhow, bail};
+use embassy_futures::select::{Either3, select3};
+use rs_matter::Matter;
+use rs_matter::cert::MAX_CERT_TLV_AND_ASN1_LEN;
+use rs_matter::cert::r#gen::VALID_FOREVER;
+use rs_matter::crypto::{
+ CanonAeadKey, CanonPkcSecretKey, Crypto, RngCore as _, SecretKey, SigningSecretKey,
+ default_crypto,
+};
+use rs_matter::dm::devices::test::{DAC_PRIVKEY, TEST_DEV_ATT, TEST_DEV_COMM, TEST_DEV_DET};
+use rs_matter::error::Error as MatterError;
+use rs_matter::fabric::FabricPersist;
+use rs_matter::onboard::cac::RcacGenerator;
+use rs_matter::onboard::noc::NocGenerator;
+use rs_matter::transport::network::mdns::CommissionableFilter;
+use rs_matter::transport::network::mdns::builtin::{BuiltinMdns, Host};
+use rs_matter::transport::network::mdns::{
+ MDNS_IPV6_BROADCAST_ADDR, MDNS_SOCKET_DEFAULT_BIND_ADDR,
+};
+use rs_matter::utils::init::InitMaybeUninit;
+use serde::{Deserialize, Serialize};
+use static_cell::StaticCell;
+
+use crate::config::Config;
+use crate::state;
+
+/// The identity material rs-matter leaves to the caller, persisted as JSON
+/// with the RCAC private key hex-encoded. Mode 0600, next to the KV blobs.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Identity {
+ pub fabric_id: u64,
+ pub controller_node_id: u64,
+ pub next_device_node_id: u64,
+ pub rcac_privkey_hex: String,
+}
+
+/// What `status` reports about the controller identity, derived purely from
+/// the on-disk artifacts (no Matter stack startup).
+#[derive(Debug)]
+pub enum IdentityStatus {
+ /// Neither identity.json nor a fabric blob: first commission creates it.
+ NotCreated,
+ Created {
+ fabric_id: u64,
+ controller_node_id: u64,
+ },
+ /// identity.json exists but cannot be read or parsed (typically
+ /// permissions: it is mode 0600, owned by the service user).
+ Unreadable,
+ /// One half of the identity pair is missing; the fabric-writing commands
+ /// will refuse or auto-recover per their guardrails.
+ Inconsistent(&'static str),
+}
+
+/// 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 fabric_exists = std::fs::read_dir(matter_kv_path(storage))
+ .map(|entries| {
+ entries
+ .flatten()
+ .any(|e| e.file_name().to_string_lossy().starts_with("k_"))
+ })
+ .unwrap_or(false);
+ match (identity_exists, fabric_exists) {
+ (false, false) => IdentityStatus::NotCreated,
+ (true, false) => {
+ IdentityStatus::Inconsistent("identity.json exists but the fabric storage is missing")
+ }
+ (false, true) => {
+ IdentityStatus::Inconsistent("fabric storage exists but identity.json is missing")
+ }
+ (true, true) => match std::fs::read_to_string(identity_path(storage))
+ .ok()
+ .and_then(|raw| serde_json::from_str::<Identity>(&raw).ok())
+ {
+ Some(identity) => IdentityStatus::Created {
+ fabric_id: identity.fabric_id,
+ controller_node_id: identity.controller_node_id,
+ },
+ None => IdentityStatus::Unreadable,
+ },
+ }
+}
+
+fn identity_path(storage: &Path) -> std::path::PathBuf {
+ storage.join("identity.json")
+}
+
+fn matter_kv_path(storage: &Path) -> std::path::PathBuf {
+ storage.join("matter")
+}
+
+impl Identity {
+ fn generate(crypto: &impl Crypto) -> Result<Self, MatterError> {
+ let mut rng = crypto.rand()?;
+ let mut bytes = [0u8; 8];
+ rng.fill_bytes(&mut bytes);
+ // Non-zero 64-bit fabric id; operational node ids for devices are
+ // small and sequential like matter.js's.
+ let fabric_id = u64::from_be_bytes(bytes) | 1;
+ let mut node_bytes = [0u8; 8];
+ rng.fill_bytes(&mut node_bytes);
+ Ok(Self {
+ fabric_id,
+ controller_node_id: u64::from_be_bytes(node_bytes) | 1,
+ next_device_node_id: 1,
+ rcac_privkey_hex: String::new(),
+ })
+ }
+
+ fn rcac_privkey(&self) -> anyhow::Result<CanonPkcSecretKey> {
+ let bytes = hex_decode(&self.rcac_privkey_hex).context("identity.json rcac key")?;
+ let mut key = CanonPkcSecretKey::new();
+ if bytes.len() != key.access_mut().len() {
+ bail!(
+ "identity.json rcac key has unexpected length {}",
+ bytes.len()
+ );
+ }
+ key.access_mut().copy_from_slice(&bytes);
+ Ok(key)
+ }
+}
+
+fn hex_encode(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn hex_decode(hex: &str) -> anyhow::Result<Vec<u8>> {
+ if !hex.len().is_multiple_of(2) {
+ bail!("odd-length hex");
+ }
+ (0..hex.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).context("bad hex digit"))
+ .collect()
+}
+
+static MATTER: StaticCell<Matter> = StaticCell::new();
+
+/// Flat-file KV store with atomic writes, stand-in for rs-matter's
+/// `DirKvBlobStore` (whose `store()` is a plain overwrite). The fabric
+/// record lives here and must never be half-written; a temp file plus
+/// rename makes every update all-or-nothing. Same `k_XXXX` file naming.
+struct AtomicKvBlobStore(std::path::PathBuf);
+
+impl AtomicKvBlobStore {
+ fn key_path(&self, key: u16) -> std::path::PathBuf {
+ self.0.join(format!("k_{key:04x}"))
+ }
+}
+
+impl rs_matter::persist::KvBlobStore for AtomicKvBlobStore {
+ fn load<'a>(&mut self, key: u16, buf: &'a mut [u8]) -> Result<Option<&'a [u8]>, MatterError> {
+ match std::fs::read(self.key_path(key)) {
+ Ok(data) => {
+ let slot = buf
+ .get_mut(..data.len())
+ .ok_or(rs_matter::error::ErrorCode::NoSpace)?;
+ slot.copy_from_slice(&data);
+ Ok(Some(slot))
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
+ Err(_) => Err(rs_matter::error::ErrorCode::StdIoError.into()),
+ }
+ }
+
+ fn store(&mut self, key: u16, data: &[u8], _buf: &mut [u8]) -> Result<(), MatterError> {
+ let path = self.key_path(key);
+ let write = || -> std::io::Result<()> {
+ std::fs::create_dir_all(self.0.as_path())?;
+ let tmp = path.with_extension("tmp");
+ state::write_private(&tmp, data)?;
+ std::fs::rename(&tmp, &path)
+ };
+ write().map_err(|_| rs_matter::error::ErrorCode::StdIoError.into())
+ }
+
+ fn remove(&mut self, key: u16, _buf: &mut [u8]) -> Result<(), MatterError> {
+ match std::fs::remove_file(self.key_path(key)) {
+ Ok(()) => Ok(()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(_) => Err(rs_matter::error::ErrorCode::StdIoError.into()),
+ }
+ }
+}
+
+/// Everything an operation needs, borrowed from the harness scope.
+pub struct Ctx<'a, C: Crypto> {
+ pub matter: &'a Matter<'a>,
+ pub crypto: &'a C,
+ pub fab_idx: NonZeroU8,
+ pub identity: Identity,
+ pub config: &'a Config,
+}
+
+/// A controller operation, generic over the crypto backend the harness
+/// picks. A trait (rather than an async closure) because Rust forbids a
+/// nested `impl Trait` in closure parameters.
+pub trait Op {
+ type Out;
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Self::Out>;
+}
+
+/// Builds the Matter stack, ensures the persistent controller fabric exists,
+/// then races the transport and mDNS pumps against `op`. The pumps never
+/// finish on their own; `op` completing ends the run.
+pub fn run<O: Op>(config: &Config, op: O) -> anyhow::Result<O::Out> {
+ std::fs::create_dir_all(&config.storage_path).context("create storage directory")?;
+ restrict_dir_mode(&config.storage_path)?;
+ // One controller process per storage directory. Held for the process
+ // lifetime (flock releases on exit, so no stale-lock handling); this
+ // makes the first-run identity bootstrap race-free when a manual run
+ // overlaps the timer.
+ let _lock = lock_storage(&config.storage_path)?;
+
+ futures_lite::future::block_on(async {
+ let crypto = default_crypto(rand::thread_rng(), DAC_PRIVKEY);
+ let matter: &'static Matter =
+ MATTER
+ .uninit()
+ .init_with(Matter::init(&TEST_DEV_DET, TEST_DEV_COMM, &TEST_DEV_ATT, 0));
+
+ let kv_store = AtomicKvBlobStore(matter_kv_path(&config.storage_path));
+ let kv = matter.kv(kv_store);
+ matter
+ .load_persist(&kv)
+ .await
+ .map_err(|e| anyhow!("load persisted Matter state: {e:?}"))?;
+
+ let (fab_idx, identity) = ensure_fabric(config, matter, &crypto, &kv)?;
+ let ctx = Ctx {
+ matter,
+ crypto: &crypto,
+ fab_idx,
+ identity,
+ config,
+ };
+
+ // Matter transport on an ephemeral port.
+ let socket =
+ async_io::Async::<UdpSocket>::bind(([0u16; 8], 0)).context("bind Matter UDP socket")?;
+ let transport = matter.run(&crypto, &socket, &socket, &socket);
+
+ // mDNS: the builtin responder everywhere. On the deployment
+ // platform (Linux) it owns port 5353 outright and behaves
+ // deterministically. On a macOS dev machine it shares 5353 with
+ // mDNSResponder and resolve answers occasionally race to the wrong
+ // socket; the connect retries absorb most of that. (The system
+ // dnssd backend was tried and is worse: its 10-item browse channel
+ // deterministically drops records on Matter-dense networks.)
+ let mdns_socket = bind_mdns_socket().context("bind mDNS socket")?;
+ let (mdns_host_ipv4, mdns_ipv6, mdns_interface) = pick_interface()?;
+ let hostname = format!(
+ "{:012X}",
+ ctx.identity.controller_node_id & 0xFFFF_FFFF_FFFF
+ );
+ let host = Host {
+ hostname: &hostname,
+ ip: mdns_host_ipv4,
+ ipv6: mdns_ipv6,
+ };
+ let mut mdns_runner = BuiltinMdns::new();
+ // ipv4_interface: None disables the runner's IPv4 send path
+ // entirely; see bind_mdns_socket for why mDNS is IPv6-only here.
+ let mdns = mdns_runner.run(
+ &mdns_socket,
+ &mdns_socket,
+ &host,
+ None,
+ Some(mdns_interface),
+ matter,
+ &crypto,
+ );
+
+ let mut transport = pin!(transport);
+ let mut mdns = pin!(mdns);
+ let mut op_fut = pin!(op.run(&ctx));
+
+ match select3(&mut transport, &mut mdns, &mut op_fut).await {
+ Either3::First(r) => bail!("Matter transport exited prematurely: {r:?}"),
+ Either3::Second(r) => bail!("mDNS runner exited prematurely: {r:?}"),
+ Either3::Third(result) => result,
+ }
+ })
+}
+
+/// Loads or bootstraps the controller fabric. On first run this mints the
+/// RCAC (whose private key we keep, RCAC-direct mode), our own operational
+/// NOC, and the fabric IPK, then installs and persists the fabric. On later
+/// runs the fabric comes back via rs-matter's KV persistence and only the
+/// label is reconciled with the configuration.
+fn ensure_fabric<C: Crypto>(
+ config: &Config,
+ matter: &Matter<'static>,
+ crypto: &C,
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+) -> anyhow::Result<(NonZeroU8, Identity)> {
+ 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));
+
+ if let Some(fab_idx) = existing {
+ match std::fs::read_to_string(&identity_file) {
+ Ok(raw) => {
+ let identity: Identity =
+ serde_json::from_str(&raw).context("parse identity.json")?;
+ reconcile_label(matter, crypto, kv, fab_idx, &config.fabric_label)?;
+ return Ok((fab_idx, identity));
+ }
+ // A fabric blob without identity.json is an interrupted first
+ // run: identity.json is written last, and commissioning requires
+ // a completed bootstrap. With an empty registry nothing can
+ // reference this fabric, so discard it and bootstrap cleanly.
+ // With a non-empty registry something is deeply wrong; never
+ // guess.
+ Err(_) if registry.nodes.is_empty() => {
+ log::warn!(
+ "Discarding fabric left by an interrupted first run (no identity.json, \
+ no commissioned devices); creating a fresh controller identity"
+ );
+ matter
+ .with_state(|state| state.fabrics.remove(fab_idx))
+ .map_err(|e| anyhow!("remove interrupted fabric: {e:?}"))?;
+ remove_persisted_fabric(kv, fab_idx)?;
+ }
+ Err(e) => {
+ bail!(
+ "cannot read {} but the node registry lists {} commissioned device(s); \
+ refusing to touch the fabric. Restore identity.json from backup. ({e})",
+ identity_file.display(),
+ registry.nodes.len()
+ );
+ }
+ }
+ }
+
+ // Refuse to mint a fresh identity over the remains of an old one. An
+ // identity.json without a loadable fabric means the KV storage was lost
+ // or damaged AFTER commissioning began; overwriting the RCAC key would
+ // permanently orphan every device commissioned to it. Deleting the whole
+ // storage directory (after decommissioning, or accepting the orphans) is
+ // a deliberate human act, never something a run does implicitly.
+ if identity_file.exists() {
+ bail!(
+ "{} exists but no fabric was loaded from {}; refusing to create a new controller \
+ identity over an old one. Restore the matter/ KV files from backup, or delete the \
+ whole storage directory to deliberately start over.",
+ identity_file.display(),
+ matter_kv_path(&config.storage_path).display()
+ );
+ }
+ if !registry.nodes.is_empty() {
+ bail!(
+ "the node registry lists {} commissioned device(s) but no fabric was loaded; \
+ refusing to create a new controller identity. Restore the storage directory from \
+ backup.",
+ registry.nodes.len()
+ );
+ }
+
+ log::info!("First run: creating the controller fabric (persistent identity)");
+ let mut identity = Identity::generate(crypto).map_err(|e| anyhow!("{e:?}"))?;
+
+ let mut rcac_buf = [0u8; MAX_CERT_TLV_AND_ASN1_LEN];
+ let mut rcac_gen = RcacGenerator::new(&mut rcac_buf);
+ let (rcac_priv, rcac) = rcac_gen
+ .generate(crypto, identity.fabric_id, VALID_FOREVER)
+ .map_err(|e| anyhow!("generate RCAC: {e:?}"))?;
+ identity.rcac_privkey_hex = hex_encode(rcac_priv.reference().access());
+
+ // Our own operational identity: keypair, CSR, NOC signed by the RCAC.
+ let controller_key = crypto
+ .generate_secret_key()
+ .map_err(|e| anyhow!("generate controller key: {e:?}"))?;
+ let mut csr_buf = [0u8; 256];
+ let csr = controller_key
+ .csr(&mut csr_buf)
+ .map_err(|e| anyhow!("controller CSR: {e:?}"))?;
+ let mut controller_key_canon = CanonPkcSecretKey::new();
+ controller_key
+ .write_canon(&mut controller_key_canon)
+ .map_err(|e| anyhow!("{e:?}"))?;
+
+ let mut noc_buf = [0u8; MAX_CERT_TLV_AND_ASN1_LEN];
+ let mut noc_generator = NocGenerator::create(rcac_priv.reference(), rcac, &[], &mut noc_buf)
+ .map_err(|e| anyhow!("NOC generator: {e:?}"))?;
+ let controller_noc = noc_generator
+ .generate(crypto, csr, identity.controller_node_id, &[], VALID_FOREVER)
+ .map_err(|e| anyhow!("controller NOC: {e:?}"))?;
+
+ let mut ipk = CanonAeadKey::new();
+ crypto
+ .rand()
+ .map_err(|e| anyhow!("{e:?}"))?
+ .fill_bytes(ipk.access_mut());
+
+ let fab_idx = matter
+ .with_state(|state| {
+ let fab_idx = state
+ .fabrics
+ .add(
+ crypto,
+ controller_key_canon.reference(),
+ rcac,
+ controller_noc,
+ &[],
+ Some(ipk.reference()),
+ TEST_VENDOR_ID,
+ identity.controller_node_id,
+ )?
+ .fab_idx();
+ let _ = state.fabrics.update_label(fab_idx, &config.fabric_label);
+ Ok::<_, MatterError>(fab_idx)
+ })
+ .map_err(|e| anyhow!("install controller fabric: {e:?}"))?;
+
+ persist_fabric(matter, kv, fab_idx)?;
+
+ // Identity file after the fabric: a crash in between leaves a fabric
+ // without identity.json, which the next run reports loudly rather than
+ // silently minting a second identity.
+ state::store(&identity_file, &identity).context("write identity.json")?;
+ log::info!(
+ "Controller fabric created and persisted (fabric id {}, controller node id {})",
+ identity.fabric_id,
+ identity.controller_node_id
+ );
+ Ok((fab_idx, identity))
+}
+
+/// CSA test vendor id: the correct value for an uncertified controller.
+const TEST_VENDOR_ID: u16 = 0xFFF1;
+
+fn reconcile_label<C: Crypto>(
+ matter: &Matter<'static>,
+ _crypto: &C,
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+ fab_idx: NonZeroU8,
+ label: &str,
+) -> anyhow::Result<()> {
+ let changed = matter.with_state(|state| {
+ let current = state
+ .fabrics
+ .get(fab_idx)
+ .map(|f| f.label().to_string())
+ .unwrap_or_default();
+ if current == label {
+ return Ok::<_, MatterError>(false);
+ }
+ state.fabrics.update_label(fab_idx, label)?;
+ Ok(true)
+ });
+ match changed {
+ Ok(true) => persist_fabric(matter, kv, fab_idx),
+ Ok(false) => Ok(()),
+ Err(e) => Err(anyhow!("update local fabric label: {e:?}")),
+ }
+}
+
+fn remove_persisted_fabric(
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+ fab_idx: NonZeroU8,
+) -> anyhow::Result<()> {
+ let mut persist = FabricPersist::new(kv);
+ persist
+ .remove(fab_idx)
+ .and_then(|()| persist.run())
+ .map_err(|e: MatterError| anyhow!("remove persisted fabric blob: {e:?}"))
+}
+
+fn persist_fabric(
+ matter: &Matter<'static>,
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+ fab_idx: NonZeroU8,
+) -> anyhow::Result<()> {
+ matter
+ .with_state(|state| {
+ let fabric = state
+ .fabrics
+ .get(fab_idx)
+ .ok_or(rs_matter::error::ErrorCode::NotFound)?;
+ let mut persist = FabricPersist::new(kv);
+ persist.store(fabric)?;
+ persist.run()
+ })
+ .map_err(|e: MatterError| anyhow!("persist fabric: {e:?}"))
+}
+
+#[cfg(unix)]
+fn lock_storage(storage: &Path) -> anyhow::Result<std::fs::File> {
+ use std::os::fd::AsRawFd;
+ let path = storage.join(".lock");
+ let file = std::fs::File::create(&path).context("create storage lock file")?;
+ let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
+ if rc != 0 {
+ bail!(
+ "another mattertimesync instance is already running against {}",
+ storage.display()
+ );
+ }
+ Ok(file)
+}
+
+#[cfg(not(unix))]
+fn lock_storage(_storage: &Path) -> anyhow::Result<std::fs::File> {
+ anyhow::bail!("only unix hosts are supported")
+}
+
+#[cfg(unix)]
+fn restrict_dir_mode(path: &Path) -> anyhow::Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
+ .context("chmod storage directory")
+}
+
+#[cfg(not(unix))]
+fn restrict_dir_mode(_path: &Path) -> anyhow::Result<()> {
+ Ok(())
+}
+
+fn bind_mdns_socket() -> anyhow::Result<async_io::Async<UdpSocket>> {
+ use socket2::{Domain, Protocol, Socket, Type};
+ let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?;
+ socket.set_reuse_address(true)?;
+ // macOS requires SO_REUSEPORT (not just SO_REUSEADDR) to share 5353
+ // with the system's own mDNSResponder; Linux is lenient either way.
+ #[cfg(unix)]
+ socket.set_reuse_port(true)?;
+ socket.set_only_v6(false)?;
+ socket.bind(&MDNS_SOCKET_DEFAULT_BIND_ADDR.into())?;
+ let socket = async_io::Async::<UdpSocket>::new_nonblocking(socket.into())?;
+
+ // IPv6 only, everywhere. Matter mandates IPv6 (Thread addresses are
+ // IPv6-only and every Matter device advertises over IPv6 mDNS), so the
+ // IPv4 side would add nothing but platform variance: macOS refuses IPv4
+ // operations on an IPv6 socket that Linux tolerates. Without the group
+ // join there is no IPv4 mDNS at all, quietly and identically on every
+ // host. A failed IPv6 join, by contrast, means discovery cannot work.
+ let (_ipv4, _ipv6, interface) = pick_interface()?;
+ socket
+ .get_ref()
+ .join_multicast_v6(&MDNS_IPV6_BROADCAST_ADDR, interface)
+ .context("join mDNS IPv6 multicast group")?;
+ Ok(socket)
+}
+
+/// Picks the LAN interface for mDNS: the first non-loopback interface with
+/// both an IPv6 address (preferring link-local) and an IPv4 address.
+fn pick_interface() -> anyhow::Result<(std::net::Ipv4Addr, std::net::Ipv6Addr, u32)> {
+ let all = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
+ let candidate = |v6_filter: fn(std::net::Ipv6Addr) -> bool| {
+ all.iter()
+ .filter(|ia| !ia.is_loopback())
+ .filter_map(|ia| match ia.addr {
+ if_addrs::IfAddr::V6(ref v6) if v6_filter(v6.ip) => {
+ Some((ia.name.clone(), v6.ip, ia.index.unwrap_or(0)))
+ }
+ _ => None,
+ })
+ .find_map(|(name, ipv6, index)| {
+ all.iter()
+ .filter(|ia| ia.name == name)
+ .find_map(|ia| match ia.addr {
+ if_addrs::IfAddr::V4(ref v4) => Some((v4.ip, ipv6, index)),
+ _ => None,
+ })
+ })
+ };
+ candidate(|ip| ip.is_unicast_link_local())
+ .or_else(|| candidate(|_| true))
+ .ok_or_else(|| anyhow!("no network interface with IPv4 + IPv6 found for mDNS"))
+}
+
+/// Commissionable-browse filter for a manual pairing code.
+pub fn commissionable_filter(short_discriminator: u8) -> CommissionableFilter {
+ CommissionableFilter {
+ short_discriminator: Some(short_discriminator),
+ ..Default::default()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Operations
+// ---------------------------------------------------------------------------
+
+use rs_matter::dm::clusters::decl::basic_information::BasicInformationClient;
+use rs_matter::dm::clusters::decl::operational_credentials::OperationalCredentialsClient;
+use rs_matter::dm::clusters::decl::time_synchronization::{
+ GranularityEnum, TimeSourceEnum, TimeSynchronizationClient,
+};
+use rs_matter::dm::endpoints::ROOT_ENDPOINT_ID;
+use rs_matter::onboard::{CommissionOptions, Commissioner};
+use rs_matter::transport::exchange::Exchange;
+
+use jiff::Timestamp;
+
+use crate::pairing::Onboarding;
+use crate::state::{NodeInfo, NodeRegistry};
+
+const BROWSE_TIMEOUT_MS: u32 = 30_000;
+/// Per-phase bound for commissioning (PASE handshake + invokes, CASE + complete).
+const COMMISSION_TIMEOUT_SECS: u64 = 60;
+/// Bound for opening a CASE exchange to a commissioned node (mDNS resolve + handshake).
+const CONNECT_TIMEOUT_SECS: u64 = 30;
+
+/// Bounds a Matter operation that could otherwise hang (unreachable peer,
+/// swallowed packets). The timeout is part of the caller's log line so an
+/// operator watching a quiet log knows how long "waiting" can last.
+async fn with_timeout<T>(
+ what: &str,
+ secs: u64,
+ fut: impl Future<Output = Result<T, MatterError>>,
+) -> anyhow::Result<T> {
+ let mut fut = core::pin::pin!(fut);
+ let mut timer = core::pin::pin!(embassy_time::Timer::after(
+ embassy_time::Duration::from_secs(secs)
+ ));
+ match embassy_futures::select::select(&mut fut, &mut timer).await {
+ embassy_futures::select::Either::First(result) => {
+ result.map_err(|e| anyhow!("{what}: {e:?}"))
+ }
+ embassy_futures::select::Either::Second(()) => bail!("{what} timed out after {secs}s"),
+ }
+}
+
+pub struct CommissionOp {
+ pub onboarding: Onboarding,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CommissionOutcome {
+ pub node_id: crate::output::Id64,
+ pub fabric_id: crate::output::Id64,
+ pub vendor_name: Option<String>,
+ pub product_name: Option<String>,
+}
+
+impl Op for CommissionOp {
+ type Out = CommissionOutcome;
+
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<CommissionOutcome> {
+ let matter = ctx.matter;
+ log::info!(
+ "Discovering commissionable device (_matterc._udp, short discriminator {}, timeout {}s)",
+ self.onboarding.short_discriminator,
+ BROWSE_TIMEOUT_MS / 1000
+ );
+ let (peer_addr, _instance) = matter
+ .transport()
+ .browse_commissionable(
+ &commissionable_filter(self.onboarding.short_discriminator),
+ &[],
+ BROWSE_TIMEOUT_MS,
+ )
+ .await
+ .map_err(|e| {
+ anyhow!("no commissionable device found (is the pairing window open?): {e:?}")
+ })?;
+ log::info!("Found commissionable device at {:?}", peer_addr);
+
+ let device_node_id = ctx.identity.next_device_node_id;
+ let rcac_privkey = ctx.identity.rcac_privkey()?;
+ let mut noc_buf = [0u8; MAX_CERT_TLV_AND_ASN1_LEN];
+ let mut noc_generator =
+ NocGenerator::new(matter, rcac_privkey.reference(), ctx.fab_idx, &mut noc_buf)
+ .map_err(|e| anyhow!("NOC generator from persisted identity: {e:?}"))?;
+
+ let mut commissioner_buf = [0u8; rs_matter::cert::MAX_CERT_TLV_LEN];
+ let mut commissioner = Commissioner::new(
+ matter,
+ ctx.crypto,
+ ctx.fab_idx,
+ &mut noc_generator,
+ &mut commissioner_buf,
+ );
+ let opts = CommissionOptions {
+ // Consumer devices carry vendor DACs we cannot verify without the
+ // DCL; matter.js accepted these the same way.
+ allow_test_attestation: true,
+ ..CommissionOptions::new()
+ };
+
+ log::info!(
+ "Commissioning as node {device_node_id} (PASE phase, timeout {COMMISSION_TIMEOUT_SECS}s)"
+ );
+ let phase1 = with_timeout(
+ "commissioning over PASE",
+ COMMISSION_TIMEOUT_SECS,
+ commissioner.commission(
+ peer_addr,
+ self.onboarding.passcode,
+ &opts,
+ device_node_id,
+ VALID_FOREVER,
+ ),
+ )
+ .await?;
+ log::info!("CASE phase: completing commissioning (timeout {COMMISSION_TIMEOUT_SECS}s)");
+ with_timeout(
+ "CommissioningComplete over CASE",
+ COMMISSION_TIMEOUT_SECS,
+ commissioner.complete_via_case(peer_addr, &phase1),
+ )
+ .await?;
+
+ // Cache the device identity for the local registry; best-effort.
+ let vendor_name = read_vendor_name(ctx, device_node_id).await.ok();
+ let product_name = read_product_name(ctx, device_node_id).await.ok();
+
+ 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)?;
+
+ 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(),
+ vendor_name,
+ product_name,
+ })
+ }
+}
+
+async fn read_vendor_name<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<String> {
+ let exchange = connect(ctx, node_id).await?;
+ let mut out = String::new();
+ exchange
+ .basic_information()
+ .vendor_name_read_with(ROOT_ENDPOINT_ID, |value| {
+ out = value?.to_string();
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read vendorName: {e:?}"))?
+ .map_err(|e| anyhow!("parse vendorName: {e:?}"))?;
+ Ok(out)
+}
+
+async fn read_product_name<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<String> {
+ let exchange = connect(ctx, node_id).await?;
+ let mut out = String::new();
+ exchange
+ .basic_information()
+ .product_name_read_with(ROOT_ENDPOINT_ID, |value| {
+ out = value?.to_string();
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read productName: {e:?}"))?
+ .map_err(|e| anyhow!("parse productName: {e:?}"))?;
+ Ok(out)
+}
+
+/// How many times to attempt an mDNS resolve + CASE connect. Resolve
+/// answers can be lost per-attempt (multicast loss, macOS socket sharing),
+/// so single-shot failure is not conclusive.
+const CONNECT_ATTEMPTS: u32 = 3;
+/// Pause between connect attempts. A failed CASE handshake can linger
+/// half-open on the device, which then answers an immediate re-knock with
+/// Busy; a couple of seconds lets it reap the stale session.
+const CONNECT_RETRY_DELAY_SECS: u64 = 2;
+
+/// Opens an exchange over CASE to a commissioned node (cached session when
+/// available, mDNS operational resolve otherwise). One exchange = one IM
+/// transaction, so every read/invoke starts here.
+async fn connect<'a, C: Crypto>(ctx: &Ctx<'a, C>, node_id: u64) -> anyhow::Result<Exchange<'a>> {
+ let mut last_error = None;
+ for attempt in 1..=CONNECT_ATTEMPTS {
+ match with_timeout(
+ &format!("reaching node {node_id} (offline or unresolvable?)"),
+ CONNECT_TIMEOUT_SECS,
+ Exchange::initiate(ctx.matter, ctx.crypto, ctx.fab_idx, node_id),
+ )
+ .await
+ {
+ Ok(exchange) => return Ok(exchange),
+ Err(error) => {
+ if attempt < CONNECT_ATTEMPTS {
+ log::warn!(
+ "Node {node_id}: connect attempt {attempt}/{CONNECT_ATTEMPTS} failed \
+ ({error:#}); retrying in {CONNECT_RETRY_DELAY_SECS}s"
+ );
+ embassy_time::Timer::after(embassy_time::Duration::from_secs(
+ CONNECT_RETRY_DELAY_SECS,
+ ))
+ .await;
+ }
+ last_error = Some(error);
+ }
+ }
+ }
+ Err(last_error.expect("at least one attempt ran"))
+}
+
+// --- sync -----------------------------------------------------------------
+
+use crate::time::{ClockAssessment, MatterMicros};
+use crate::tz::{build_dst_offset_list, build_time_zone_list};
+
+const VERIFY_TOLERANCE_MICROS: i64 = 5_000_000;
+const FEATURE_TIME_ZONE: u32 = 1 << 0;
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SyncOutcome {
+ pub node_id: crate::output::Id64,
+ pub success: bool,
+ /// True when the device has no Time Synchronization cluster: reported
+ /// with a warning, not counted as a failure, so a permanently
+ /// incompatible device cannot fail every timer run.
+ pub skipped: bool,
+ pub error: Option<String>,
+ /// Human assessment of the device clock before the write.
+ pub clock_before: Option<String>,
+ pub epoch_shifted: bool,
+ pub utc_time_written: Option<String>,
+ pub time_zone_written: Option<String>,
+ pub dst_entries_written: usize,
+ pub delta_after_micros: Option<i64>,
+ pub verified: bool,
+}
+
+pub struct SyncOp {
+ pub targets: Vec<u64>,
+ /// When set, write this instant instead of the current time: the
+ /// operator is deliberately setting an arbitrary wall clock.
+ pub manual_time: Option<MatterMicros>,
+}
+
+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 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());
+ })?;
+ let outcome = match sync_one(ctx, node_id, self.manual_time).await {
+ Ok(outcome) => outcome,
+ Err(error) => SyncOutcome {
+ node_id: node_id.into(),
+ success: false,
+ skipped: false,
+ error: Some(format!("{error:#}")),
+ clock_before: None,
+ epoch_shifted: false,
+ utc_time_written: None,
+ time_zone_written: None,
+ dst_entries_written: 0,
+ delta_after_micros: None,
+ verified: false,
+ },
+ };
+ state::update_node_state(&state_path, node_id, |n| {
+ if outcome.success {
+ n.last_successful_sync = Some(Timestamp::now());
+ n.last_successful_connection = Some(Timestamp::now());
+ n.last_error = None;
+ } else {
+ n.last_error = outcome.error.clone();
+ }
+ })?;
+ if let Some(error) = &outcome.error {
+ log::error!("Node {node_id}: sync failed: {error}");
+ }
+ outcomes.push(outcome);
+ }
+ Ok(outcomes)
+ }
+}
+
+async fn sync_one<C: Crypto>(
+ ctx: &Ctx<'_, C>,
+ node_id: u64,
+ manual_time: Option<MatterMicros>,
+) -> anyhow::Result<SyncOutcome> {
+ let tz = ctx.config.time_zone();
+ let now_ts = Timestamp::now();
+
+ // Capability discovery first: a device without the cluster is skipped
+ // with a warning, and the TimeZone feature gates SetTimeZone/SetDSTOffset.
+ let feature_map = match connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .feature_map_read(ROOT_ENDPOINT_ID)
+ .await
+ {
+ Ok(map) => map,
+ Err(e) if e.code() == rs_matter::error::ErrorCode::ClusterNotFound => {
+ log::warn!("Node {node_id}: no Time Synchronization cluster; skipping");
+ return Ok(SyncOutcome {
+ node_id: node_id.into(),
+ success: false,
+ skipped: true,
+ error: Some("no Time Synchronization cluster".into()),
+ clock_before: None,
+ epoch_shifted: false,
+ utc_time_written: None,
+ time_zone_written: None,
+ dst_entries_written: 0,
+ delta_after_micros: None,
+ verified: false,
+ });
+ }
+ Err(e) => bail!("read featureMap: {e:?}"),
+ };
+ let has_time_zone = feature_map & FEATURE_TIME_ZONE != 0;
+
+ // Before: read the device clock live for the correction report.
+ let before = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .utc_time_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read utcTime: {e:?}"))?;
+ let device_before = before.into_option().map(MatterMicros);
+ let assessment = ClockAssessment::compare(device_before, MatterMicros::now());
+ log::info!("Node {node_id}: {assessment}");
+
+ // SetUTCTime, last-moment fresh. The host clock is NTP-disciplined and
+ // the timestamp microsecond-precise at send time; a device that already
+ // holds good time may reject a weaker claim (TimeNotAccepted).
+ let utc_write = manual_time.unwrap_or_else(MatterMicros::now);
+ connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .set_utc_time(ROOT_ENDPOINT_ID, |b| {
+ b.utc_time(utc_write.0)?
+ .granularity(GranularityEnum::MicrosecondsGranularity)?
+ .time_source(Some(TimeSourceEnum::NonMatterSNTP))?
+ .end()
+ })
+ .await
+ .map_err(|e| anyhow!("SetUTCTime rejected: {e:?}"))?;
+ 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
+ .map_err(|e| anyhow!("SetTimeZone rejected: {e:?}"))?;
+ let dst_required = response
+ .response()
+ .map(|r| r.dst_offset_required().unwrap_or(true))
+ .unwrap_or(true);
+ response
+ .complete()
+ .await
+ .map_err(|e| anyhow!("SetTimeZone completion: {e:?}"))?;
+ 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
+ .map_err(|e| anyhow!("read dstOffsetListMaxSize: {e:?}"))?;
+ 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
+ .map_err(|e| anyhow!("SetDSTOffset rejected: {e:?}"))?;
+ dst_entries_written = dst_entries.len();
+ log::info!("Node {node_id}: SetDSTOffset with {dst_entries_written} entries");
+ }
+ }
+
+ // Verify by reading the clock back.
+ let after = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .utc_time_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read-back utcTime: {e:?}"))?;
+ // Verify against what was WRITTEN (not against "now"): the question is
+ // whether the device accepted our value, which also makes verification
+ // correct when a manual time was set deliberately far from now.
+ let after_assessment =
+ ClockAssessment::compare(after.into_option().map(MatterMicros), utc_write);
+ let delta_after = after_assessment.effective_delta_micros();
+ let verified = delta_after.is_some_and(|d| d.abs() <= VERIFY_TOLERANCE_MICROS);
+ if !verified {
+ bail!("read-back verification failed: {after_assessment}");
+ }
+
+ ensure_fabric_label(ctx, node_id).await;
+
+ Ok(SyncOutcome {
+ node_id: node_id.into(),
+ success: true,
+ skipped: false,
+ error: None,
+ clock_before: Some(assessment.to_string()),
+ epoch_shifted: assessment.is_epoch_shifted(),
+ utc_time_written: Some(utc_write.to_string()),
+ time_zone_written,
+ dst_entries_written,
+ delta_after_micros: delta_after,
+ verified,
+ })
+}
+
+/// 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
+/// logged with guidance rather than retried.
+async fn ensure_fabric_label<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) {
+ if let Err(error) = try_ensure_fabric_label(ctx, node_id).await {
+ log::warn!("Node {node_id}: could not update fabric label: {error:#}");
+ }
+}
+
+async fn try_ensure_fabric_label<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<()> {
+ let wanted = ctx.config.fabric_label.clone();
+ let mut current: Option<String> = None;
+ connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .fabrics_read_with(ROOT_ENDPOINT_ID, |reader| {
+ // Fabric-filtered read: the device returns only our own entry.
+ for item in reader? {
+ current = Some(item?.label()?.to_string());
+ }
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read fabrics: {e:?}"))?
+ .map_err(|e| anyhow!("parse fabrics: {e:?}"))?;
+
+ if current.as_deref() == Some(wanted.as_str()) {
+ return Ok(());
+ }
+ let response = connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .update_fabric_label(ROOT_ENDPOINT_ID, |b| b.label(&wanted)?.end())
+ .await
+ .map_err(|e| anyhow!("UpdateFabricLabel: {e:?}"))?;
+ let status = response.response().map(|r| r.status_code());
+ response
+ .complete()
+ .await
+ .map_err(|e| anyhow!("UpdateFabricLabel completion: {e:?}"))?;
+ log::info!("Node {node_id}: fabric label updated to {wanted:?} (status {status:?})");
+ Ok(())
+}
+
+// --- decommission ----------------------------------------------------------
+
+pub struct DecommissionOp {
+ pub targets: Vec<u64>,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DecommissionOutcome {
+ pub node_id: crate::output::Id64,
+ pub success: bool,
+ pub error: Option<String>,
+}
+
+impl Op for DecommissionOp {
+ type Out = Vec<DecommissionOutcome>;
+
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Vec<DecommissionOutcome>> {
+ let mut outcomes = Vec::with_capacity(self.targets.len());
+ for node_id in self.targets {
+ 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,
+ )?;
+ outcomes.push(DecommissionOutcome {
+ node_id: node_id.into(),
+ success: true,
+ error: None,
+ });
+ }
+ Err(error) => {
+ log::error!("Node {node_id}: decommission failed: {error:#}");
+ outcomes.push(DecommissionOutcome {
+ node_id: node_id.into(),
+ success: false,
+ error: Some(format!("{error:#}")),
+ });
+ }
+ }
+ }
+ Ok(outcomes)
+ }
+}
+
+/// The device drops this controller's fabric via RemoveFabric on our own
+/// entry (found through a fabric-filtered read, so no other admin's entry
+/// can even be addressed), while staying paired to its primary ecosystem.
+async fn decommission_one<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<()> {
+ let mut our_index: Option<u8> = None;
+ connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .fabrics_read_with(ROOT_ENDPOINT_ID, |reader| {
+ for item in reader? {
+ our_index = item?.fabric_index()?;
+ }
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read fabrics: {e:?}"))?
+ .map_err(|e| anyhow!("parse fabrics: {e:?}"))?;
+ let our_index = our_index.ok_or_else(|| anyhow!("device has no entry for our fabric"))?;
+
+ log::info!(
+ "Decommissioning: removing our fabric (device index {our_index}) from node {node_id}"
+ );
+ let response = connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .remove_fabric(ROOT_ENDPOINT_ID, |b| b.fabric_index(our_index)?.end())
+ .await
+ .map_err(|e| anyhow!("RemoveFabric: {e:?}"))?;
+ let status = response.response().map(|r| r.status_code());
+ response
+ .complete()
+ .await
+ .map_err(|e| anyhow!("RemoveFabric completion: {e:?}"))?;
+ log::info!("Node {node_id}: fabric removed (status {status:?})");
+ Ok(())
+}
+
+// --- inspect ---------------------------------------------------------------
+
+pub struct InspectOp {
+ pub targets: Vec<u64>,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InspectOutcome {
+ pub node_id: crate::output::Id64,
+ pub error: Option<String>,
+ pub vendor_name: Option<String>,
+ pub product_name: Option<String>,
+ pub time_sync: Option<TimeSyncCaps>,
+ pub our_fabric_label: Option<String>,
+ pub our_fabric_index: Option<u8>,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TimeSyncCaps {
+ pub feature_map: u32,
+ pub time_zone_feature: bool,
+ pub utc_time: Option<String>,
+ pub granularity: u8,
+ pub dst_offset_list_max_size: u8,
+}
+
+impl Op for InspectOp {
+ type Out = Vec<InspectOutcome>;
+
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Vec<InspectOutcome>> {
+ let mut outcomes = Vec::with_capacity(self.targets.len());
+ for node_id in self.targets {
+ match inspect_one(ctx, node_id).await {
+ 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,
+ });
+ }
+ }
+ }
+ Ok(outcomes)
+ }
+}
+
+async fn inspect_one<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<InspectOutcome> {
+ let vendor_name = read_vendor_name(ctx, node_id).await.ok();
+ let product_name = read_product_name(ctx, node_id).await.ok();
+
+ let feature_map = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .feature_map_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read featureMap (device may lack Time Synchronization): {e:?}"))?;
+ let utc_time = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .utc_time_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read utcTime: {e:?}"))?
+ .into_option()
+ .map(|v| MatterMicros(v).to_string());
+ let granularity = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .granularity_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read granularity: {e:?}"))? as u8;
+ let has_tz = feature_map & FEATURE_TIME_ZONE != 0;
+ let dst_max = if has_tz {
+ connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .dst_offset_list_max_size_read(ROOT_ENDPOINT_ID)
+ .await
+ .unwrap_or(1)
+ } else {
+ 0
+ };
+
+ let mut label = None;
+ let mut index = None;
+ connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .fabrics_read_with(ROOT_ENDPOINT_ID, |reader| {
+ for item in reader? {
+ let item = item?;
+ label = Some(item.label()?.to_string());
+ index = item.fabric_index()?;
+ }
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read fabrics: {e:?}"))?
+ .map_err(|e| anyhow!("parse fabrics: {e:?}"))?;
+
+ Ok(InspectOutcome {
+ node_id: node_id.into(),
+ error: None,
+ vendor_name,
+ product_name,
+ time_sync: Some(TimeSyncCaps {
+ feature_map,
+ time_zone_feature: has_tz,
+ utc_time,
+ granularity,
+ dst_offset_list_max_size: dst_max,
+ }),
+ our_fabric_label: label,
+ our_fabric_index: index,
+ })
+}
diff --git a/src/host.rs b/src/host.rs
new file mode 100644
index 0000000..feb5a34
--- /dev/null
+++ b/src/host.rs
@@ -0,0 +1,88 @@
+//! Host clock trust: the device must never be set from a clock we do not
+//! trust.
+//!
+//! The primary check measures the actual clock error with a single SNTP
+//! query (RFC 4330) and accepts the host when the offset is under a second.
+//! That is a direct measurement, platform-independent, and stronger than
+//! asking the OS whether it believes it is synchronized. When no NTP server
+//! is reachable, systemd-timesyncd's verdict (`timedatectl`, Linux) is the
+//! fallback; hosts with neither fail safe.
+
+use std::net::UdpSocket;
+use std::process::Command;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+const NTP_SERVERS: [&str; 2] = ["time.apple.com:123", "pool.ntp.org:123"];
+/// Accept the host clock when it is within this many seconds of NTP time.
+/// Well inside the 5s sync read-back tolerance, far above network jitter.
+const MAX_OFFSET_SECONDS: f64 = 1.0;
+/// Seconds between the NTP era (1900) and the Unix epoch (1970).
+const NTP_UNIX_OFFSET: f64 = 2_208_988_800.0;
+/// The NTP fraction field is 32-bit fixed-point in units of 1/2^32 seconds;
+/// dividing by 2^32 converts it to seconds.
+const NTP_FRACTION_SCALE: f64 = (1u64 << 32) as f64;
+
+pub fn clock_is_ntp_synchronized() -> bool {
+ for server in NTP_SERVERS {
+ if let Some(offset) = sntp_offset(server) {
+ let ok = offset.abs() <= MAX_OFFSET_SECONDS;
+ if ok {
+ log::debug!("Host clock is {offset:+.3}s from {server}; trusted");
+ } else {
+ log::warn!("Host clock is {offset:+.3}s from {server}; not trusted");
+ }
+ return ok;
+ }
+ }
+ log::debug!("No NTP server reachable; falling back to timedatectl");
+ timedatectl_says_synchronized()
+}
+
+/// One SNTP client exchange: returns the approximate offset of the local
+/// clock relative to the server (positive = local clock ahead). Uses the
+/// request/response midpoint, so the error is bounded by half the round
+/// trip, which is milliseconds against a threshold of a second.
+fn sntp_offset(server: &str) -> Option<f64> {
+ let socket = UdpSocket::bind(("0.0.0.0", 0)).ok()?;
+ socket.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
+ socket.connect(server).ok()?;
+
+ let mut request = [0u8; 48];
+ request[0] = 0b00_100_011; // LI 0, version 4, mode 3 (client)
+ let sent_at = unix_now();
+ socket.send(&request).ok()?;
+
+ let mut response = [0u8; 48];
+ let len = socket.recv(&mut response).ok()?;
+ let received_at = unix_now();
+ if len < 48 || response[0] & 0x07 != 4 {
+ // Not a server-mode reply.
+ return None;
+ }
+
+ // Transmit timestamp: seconds since 1900 plus a 32-bit binary fraction.
+ let seconds = u32::from_be_bytes(response[40..44].try_into().ok()?) as f64;
+ let fraction =
+ u32::from_be_bytes(response[44..48].try_into().ok()?) as f64 / NTP_FRACTION_SCALE;
+ let server_time = seconds + fraction - NTP_UNIX_OFFSET;
+ if server_time <= 0.0 {
+ return None;
+ }
+ Some((sent_at + received_at) / 2.0 - server_time)
+}
+
+fn unix_now() -> f64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs_f64())
+ .unwrap_or(0.0)
+}
+
+/// systemd-timesyncd's opinion; false on hosts without timedatectl.
+fn timedatectl_says_synchronized() -> bool {
+ Command::new("timedatectl")
+ .args(["show", "-p", "NTPSynchronized", "--value"])
+ .output()
+ .map(|output| output.status.success() && output.stdout.trim_ascii() == b"yes")
+ .unwrap_or(false)
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..e36ad21
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,374 @@
+//! mattertimesync: a one-shot CLI Matter controller that sets the clocks of
+//! Matter devices via the standard Time Synchronization cluster.
+//!
+//! # CLI invariants
+//!
+//! Every command upholds these; new commands and options must too. Numbers
+//! 1, 2 and 5 are enforced structurally by the [`output::Output`] type:
+//! handlers return a value of a closed enum instead of printing, the exit
+//! code is derived from that value, and both renderings consume it.
+//!
+//! 1. **Channels.** stdout carries command output only; all logs go to
+//! stderr. With `--json`, stdout is exactly one pretty-printed JSON
+//! object with a descriptive top-level key ({"nodes": ...},
+//! {"commissioned": ...}), on failure included ({"error": ...}).
+//! 2. **Exit codes.** 0 = success, including empty-but-valid results;
+//! 1 = runtime failure, including any per-node failure; 2 = usage or
+//! configuration error. Devices skipped as incompatible do not fail a
+//! run.
+//! 3. **--node semantics.** The local read-only command (status) takes a
+//! [`Filter`]: an absent node yields an empty report, never an error.
+//! Device-connecting commands (inspect, sync, decommission) take a
+//! [`Target`]: the node must be commissioned, otherwise error.
+//! commission takes neither because the node ID does not exist until it
+//! assigns one.
+//! 4. **Default targeting.** Device-connecting commands operate on every
+//! commissioned device when --node is omitted. On an empty registry,
+//! read-only inspect reports emptiness successfully; fabric-writing
+//! sync and decommission error, because having nothing to write to is
+//! an operator problem worth surfacing.
+//! 5. **Per-node isolation.** Multi-node runs never abort the batch on one
+//! node's failure; every node gets its own outcome entry.
+//! 6. **Universal options.** -c/--config and -j/--json exist everywhere;
+//! -n/--node everywhere except commission.
+
+mod config;
+mod controller;
+mod host;
+mod output;
+mod pairing;
+mod state;
+mod time;
+mod tz;
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::{Parser, Subcommand};
+use jiff::Timestamp;
+
+use crate::config::{Config, DEFAULT_CONFIG_PATH};
+use crate::output::{
+ ConfigReport, DstTransition, IdentityReport, NodeListing, Output, StatusReport, SyncReport,
+};
+use crate::state::{NodeRegistry, ServiceState};
+use crate::time::MatterMicros;
+
+#[derive(Parser)]
+#[command(
+ name = "mattertimesync",
+ version,
+ about,
+ disable_help_subcommand = true
+)]
+struct Cli {
+ /// Configuration file
+ #[arg(short = 'c', long, global = true, default_value = DEFAULT_CONFIG_PATH)]
+ config: PathBuf,
+ #[command(subcommand)]
+ command: Option<Command>,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// Show controller and per-device state [local, read-only]
+ Status {
+ /// Machine-readable output (64-bit values as decimal strings)
+ #[arg(short = 'j', long)]
+ json: bool,
+ /// Show only this device's state
+ #[arg(short = 'n', long)]
+ node: Option<u64>,
+ },
+ /// Connect to each commissioned device (or one with --node) and dump
+ /// identity, time-sync capabilities, and our fabric entry
+ /// [fabric interrogation, read-only]
+ Inspect {
+ /// Inspect only this device
+ #[arg(short = 'n', long)]
+ node: Option<u64>,
+ /// Machine-readable output (64-bit values as decimal strings)
+ #[arg(short = 'j', long)]
+ json: bool,
+ },
+ /// Join a device as an additional Matter admin; the pairing code is
+ /// used once, never logged or stored [fabric-writing]
+ Commission {
+ /// Pairing code from the primary ecosystem's pairing mode
+ pairing_code: String,
+ /// Machine-readable output (64-bit values as decimal strings)
+ #[arg(short = 'j', long)]
+ json: bool,
+ },
+ /// Set each device's clock: UTC time, time zone, DST offsets
+ /// [fabric-writing]
+ Sync {
+ /// Target one device instead of all commissioned devices
+ #[arg(short = 'n', long)]
+ node: Option<u64>,
+ /// Set this wall-clock time (e.g. 16:35 or 4:35pm, today in the
+ /// configured time zone) instead of the current time
+ #[arg(short = 't', long, value_name = "TIME")]
+ time: Option<String>,
+ /// Machine-readable output (64-bit values as decimal strings)
+ #[arg(short = 'j', long)]
+ json: bool,
+ },
+ /// Drop this controller's fabric from each device, or one with --node;
+ /// primary ecosystems are untouched [fabric-writing]
+ Decommission {
+ /// Target one device instead of all commissioned devices
+ #[arg(short = 'n', long)]
+ node: Option<u64>,
+ /// Machine-readable output (64-bit values as decimal strings)
+ #[arg(short = 'j', long)]
+ json: bool,
+ },
+}
+
+fn main() -> ExitCode {
+ let cli = Cli::parse();
+
+ // No command: show the full help, not a terse error.
+ let Some(command) = cli.command else {
+ use clap::CommandFactory;
+ let _ = Cli::command().print_help();
+ return ExitCode::from(2);
+ };
+
+ let config = match Config::load(&cli.config) {
+ Ok(config) => config,
+ Err(error) => {
+ eprintln!("Configuration error: {error}");
+ return ExitCode::from(2);
+ }
+ };
+
+ env_logger::Builder::new()
+ .filter_level(config.log_level.into())
+ .format_timestamp_millis()
+ .init();
+
+ for warning in config.path_warnings() {
+ log::warn!("{warning}");
+ }
+
+ let (json, result) = match command {
+ Command::Status { json, node } => (json, run_status(&config, Filter(node))),
+ Command::Inspect { json, node } => (json, run_inspect(&config, Target(node))),
+ Command::Sync { json, node, time } => {
+ (json, run_sync(&config, Target(node), time.as_deref()))
+ }
+ Command::Commission { json, pairing_code } => {
+ (json, run_commission(&config, &pairing_code))
+ }
+ Command::Decommission { json, node } => (json, run_decommission(&config, Target(node))),
+ };
+
+ let output = result.unwrap_or_else(|error| {
+ log::error!("{error:#}");
+ Output::Error {
+ error: format!("{error:#}"),
+ }
+ });
+ if json {
+ output.print_json();
+ } else {
+ output.render_human();
+ }
+ output.exit_code()
+}
+
+/// `--node` on a local, read-only command: filters a report. An absent node
+/// yields an empty report, never an error.
+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);
+ }
+ }
+}
+
+/// `--node` on a device-connecting command: selects targets. A requested
+/// node must be commissioned; without one, every commissioned device.
+struct Target(Option<u64>);
+
+impl Target {
+ /// Targets for fabric-writing commands: an empty registry is an error
+ /// (having nothing to write to is an operator problem worth surfacing).
+ fn resolve(&self, config: &Config) -> anyhow::Result<Vec<u64>> {
+ let targets = self.resolve_or_empty(config)?;
+ if targets.is_empty() {
+ anyhow::bail!("no devices are commissioned yet; run \"commission\" to add one");
+ }
+ Ok(targets)
+ }
+
+ /// 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();
+ match self.0 {
+ None => Ok(known),
+ Some(node) if known.contains(&node) => Ok(vec![node]),
+ Some(node) => anyhow::bail!(
+ "node {node} is not commissioned on this controller (known nodes: {})",
+ if known.is_empty() {
+ "none".into()
+ } else {
+ known
+ .iter()
+ .map(u64::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
+ .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.
+ 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,
+ })
+ })
+ .collect();
+
+ let identity = match controller::identity_status(&config.storage_path) {
+ controller::IdentityStatus::NotCreated => IdentityReport::NotCreated,
+ controller::IdentityStatus::Created {
+ fabric_id,
+ controller_node_id,
+ } => IdentityReport::Created {
+ fabric_id: fabric_id.to_string(),
+ controller_node_id: controller_node_id.to_string(),
+ },
+ controller::IdentityStatus::Unreadable => IdentityReport::Unreadable,
+ controller::IdentityStatus::Inconsistent(reason) => IdentityReport::Inconsistent {
+ reason: reason.to_string(),
+ },
+ };
+ let offset_seconds = tz.to_offset(now).seconds();
+ Ok(Output::Status(Box::new(StatusReport {
+ config: ConfigReport {
+ source: config.source.clone(),
+ storage_path: config.storage_path.clone(),
+ timezone: config.timezone.clone(),
+ log_level: config.log_level.to_string(),
+ fabric_label: config.fabric_label.clone(),
+ },
+ storage_initialized: config.storage_path.is_dir(),
+ controller_identity: identity,
+ host_ntp_synchronized: host::clock_is_ntp_synchronized(),
+ current_utc_offset_seconds: offset_seconds,
+ current_utc_offset: tz::format_utc_offset(offset_seconds),
+ next_dst_transition: tz::next_offset_transition(&tz, now).map(|(at, before, after)| {
+ DstTransition {
+ at: at.to_string(),
+ offset_before_seconds: before,
+ offset_after_seconds: after,
+ }
+ }),
+ matter_time_now_microseconds: MatterMicros::now().0.to_string(),
+ nodes,
+ })))
+}
+
+fn run_inspect(config: &Config, target: Target) -> anyhow::Result<Output> {
+ let targets = target.resolve_or_empty(config)?;
+ let nodes = if targets.is_empty() {
+ Vec::new()
+ } else {
+ controller::run(config, controller::InspectOp { targets })?
+ };
+ Ok(Output::Inspection { nodes })
+}
+
+fn run_sync(config: &Config, target: Target, time: Option<&str>) -> anyhow::Result<Output> {
+ // --time makes the operator the time source: compute today's date in
+ // the configured zone at the requested wall-clock time.
+ let manual_time = match time {
+ None => None,
+ Some(raw) => {
+ let wall = crate::time::parse_wall_clock(raw).map_err(|e| anyhow::anyhow!(e))?;
+ let tz = config.time_zone();
+ let today = Timestamp::now().to_zoned(tz.clone()).date();
+ let instant = today
+ .at(wall.hour(), wall.minute(), wall.second(), 0)
+ .to_zoned(tz)
+ .map_err(|e| anyhow::anyhow!("cannot place {raw:?} in {}: {e}", config.timezone))?
+ .timestamp();
+ Some(MatterMicros::from_timestamp(instant))
+ }
+ };
+
+ // Fail safe: never push time from a clock that is not NTP-disciplined.
+ // Not enforced with --time: the operator deliberately chose the value.
+ let ntp = host::clock_is_ntp_synchronized();
+ if !ntp && manual_time.is_none() {
+ log::warn!("Host clock is not NTP-synchronized; refusing to set device time.");
+ return Ok(Output::Sync(SyncReport {
+ host_ntp_synchronized: false,
+ manual_time: None,
+ nodes: Vec::new(),
+ }));
+ }
+ let targets = target.resolve(config)?;
+ let nodes = controller::run(
+ config,
+ controller::SyncOp {
+ targets,
+ manual_time,
+ },
+ )?;
+ Ok(Output::Sync(SyncReport {
+ host_ntp_synchronized: ntp,
+ manual_time: manual_time.map(|m| m.to_string()),
+ nodes,
+ }))
+}
+
+fn run_commission(config: &Config, code: &str) -> anyhow::Result<Output> {
+ let onboarding = pairing::parse_manual_code(code)?;
+ let commissioned = controller::run(config, controller::CommissionOp { onboarding })?;
+ Ok(Output::Commissioned {
+ nodes: vec![commissioned],
+ })
+}
+
+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));
+ Ok(Output::Decommission {
+ nodes,
+ remaining_nodes: registry.nodes.into_keys().collect(),
+ })
+}
diff --git a/src/output.rs b/src/output.rs
new file mode 100644
index 0000000..4de5745
--- /dev/null
+++ b/src/output.rs
@@ -0,0 +1,444 @@
+//! The typed output layer.
+//!
+//! Every command handler returns an [`Output`]; printing happens exactly
+//! once, in `main`, from that value. The CLI invariants are enforced here by
+//! construction rather than by convention:
+//!
+//! - the set of top-level JSON shapes is closed (this enum), so a command
+//! cannot invent an envelope or emit a naked array;
+//! - JSON formatting is uniform because [`print_json`] is the only printer;
+//! - the exit code is derived from the output value by [`Output::exit_code`],
+//! so it cannot disagree with what was reported;
+//! - human and JSON renderings are fed by the same data, so they cannot
+//! drift apart in content.
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use jiff::Timestamp;
+use serde::Serialize;
+
+use crate::controller::{CommissionOutcome, DecommissionOutcome, InspectOutcome, SyncOutcome};
+use crate::time::CompactDuration;
+use crate::tz::format_utc_offset;
+
+/// A 64-bit Matter identifier (node ID, fabric ID). Serializes as a decimal
+/// string, never as a JSON number, which silently loses precision beyond
+/// 2^53 in many consumers. The rule lives in the type, so an ID field
+/// cannot be emitted wrongly.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Id64(pub u64);
+
+impl Serialize for Id64 {
+ fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.0.to_string())
+ }
+}
+
+impl std::fmt::Display for Id64 {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl From<u64> for Id64 {
+ fn from(value: u64) -> Self {
+ Self(value)
+ }
+}
+
+/// Everything a command can say. Untagged: each variant serializes as its
+/// own object with descriptive top-level keys.
+#[derive(Debug, Serialize)]
+#[serde(untagged)]
+pub enum Output {
+ Status(Box<StatusReport>),
+ Inspection {
+ nodes: Vec<InspectOutcome>,
+ },
+ Sync(SyncReport),
+ Commissioned {
+ nodes: Vec<CommissionOutcome>,
+ },
+ #[serde(rename_all = "camelCase")]
+ Decommission {
+ nodes: Vec<DecommissionOutcome>,
+ remaining_nodes: Vec<String>,
+ },
+ Error {
+ error: String,
+ },
+}
+
+/// Everything that was parsed from the configuration file, echoed back so
+/// an operator can see exactly what the tool is running with.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConfigReport {
+ pub source: PathBuf,
+ pub storage_path: PathBuf,
+ pub timezone: String,
+ pub log_level: String,
+ pub fabric_label: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct StatusReport {
+ pub config: ConfigReport,
+ pub storage_initialized: bool,
+ pub controller_identity: IdentityReport,
+ pub host_ntp_synchronized: bool,
+ pub current_utc_offset_seconds: i32,
+ pub current_utc_offset: String,
+ pub next_dst_transition: Option<DstTransition>,
+ pub matter_time_now_microseconds: String,
+ /// One entry per commissioned device: registry identity merged with
+ /// the latest sync state.
+ pub nodes: Vec<NodeListing>,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(
+ tag = "state",
+ rename_all = "camelCase",
+ rename_all_fields = "camelCase"
+)]
+pub enum IdentityReport {
+ NotCreated,
+ Created {
+ fabric_id: String,
+ controller_node_id: String,
+ },
+ Unreadable,
+ Inconsistent {
+ reason: String,
+ },
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DstTransition {
+ pub at: String,
+ pub offset_before_seconds: i32,
+ pub offset_after_seconds: i32,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NodeListing {
+ pub node_id: Id64,
+ 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>,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SyncReport {
+ pub host_ntp_synchronized: bool,
+ /// Set when the operator supplied --time: the written instant.
+ pub manual_time: Option<String>,
+ pub nodes: Vec<SyncOutcome>,
+}
+
+impl Output {
+ /// The exit code is a function of the reported outcome; a handler cannot
+ /// return one that disagrees with its output. 0 = success (including
+ /// empty-but-valid results), 1 = any failure. Skipped devices are not
+ /// failures.
+ pub fn exit_code(&self) -> ExitCode {
+ let ok = match self {
+ Output::Status(_) | Output::Commissioned { .. } => true,
+ Output::Inspection { nodes } => nodes.iter().all(|n| n.error.is_none()),
+ Output::Sync(report) => {
+ // With --time the operator is the time source; the NTP
+ // verdict is informational only.
+ (report.host_ntp_synchronized || report.manual_time.is_some())
+ && report.nodes.iter().all(|n| n.success || n.skipped)
+ }
+ Output::Decommission { nodes, .. } => nodes.iter().all(|n| n.success),
+ Output::Error { .. } => false,
+ };
+ if ok {
+ ExitCode::SUCCESS
+ } else {
+ ExitCode::FAILURE
+ }
+ }
+
+ /// The one JSON printer: pretty, one object, always parseable.
+ pub fn print_json(&self) {
+ match serde_json::to_string_pretty(self) {
+ Ok(body) => println!("{body}"),
+ Err(error) => println!("{{\"error\": \"serialize output: {error}\"}}"),
+ }
+ }
+
+ pub fn render_human(&self) {
+ match self {
+ Output::Status(report) => render_status(report),
+ Output::Inspection { nodes } => render_inspection(nodes),
+ Output::Sync(report) => render_sync(report),
+ Output::Commissioned { nodes } => nodes.iter().for_each(render_commissioned),
+ Output::Decommission {
+ nodes,
+ remaining_nodes,
+ } => render_decommission(nodes, remaining_nodes),
+ // Failures are already on stderr via the log; stdout stays quiet.
+ Output::Error { .. } => {}
+ }
+ }
+}
+
+fn display_instant(at: Option<Timestamp>) -> String {
+ at.map_or_else(|| "never".into(), |at| at.to_string())
+}
+
+fn render_status(report: &StatusReport) {
+ println!(
+ "Configuration: loaded from {}",
+ report.config.source.display()
+ );
+ println!(
+ " storagePath: {}",
+ report.config.storage_path.display()
+ );
+ println!(" timezone: {}", report.config.timezone);
+ println!(" logLevel: {}", report.config.log_level);
+ println!(" fabricLabel: {}", report.config.fabric_label);
+ println!(
+ "Controller storage: {}",
+ if report.storage_initialized {
+ format!("present at {}", report.config.storage_path.display())
+ } else {
+ "NOT INITIALIZED".into()
+ }
+ );
+ match &report.controller_identity {
+ IdentityReport::NotCreated => {
+ println!("Controller identity: not created yet (\"commission\" will create it)")
+ }
+ IdentityReport::Created {
+ fabric_id,
+ controller_node_id,
+ } => println!(
+ "Controller identity: created (fabric id {fabric_id}, controller node id {controller_node_id})"
+ ),
+ IdentityReport::Unreadable => {
+ println!("Controller identity: present but unreadable (run as the service user?)")
+ }
+ IdentityReport::Inconsistent { reason } => {
+ println!("Controller identity: INCONSISTENT: {reason}")
+ }
+ }
+ println!(
+ "Commissioned nodes: {}",
+ if report.nodes.is_empty() {
+ "none recorded".into()
+ } else {
+ report
+ .nodes
+ .iter()
+ .map(|n| n.node_id.to_string())
+ .collect::<Vec<_>>()
+ .join(", ")
+ }
+ );
+ println!(
+ "Host NTP synced: {}",
+ if report.host_ntp_synchronized {
+ "yes"
+ } else {
+ "no (or not determinable on this host)"
+ }
+ );
+ println!("Current UTC offset: {}", report.current_utc_offset);
+ match &report.next_dst_transition {
+ Some(transition) => println!(
+ "Next DST transition: {} ({} -> {})",
+ transition.at,
+ format_utc_offset(transition.offset_before_seconds),
+ format_utc_offset(transition.offset_after_seconds)
+ ),
+ None => println!("Next DST transition: none (fixed-offset zone)"),
+ }
+ println!(
+ "Matter time now: {} us since 2000-01-01T00:00:00Z",
+ report.matter_time_now_microseconds
+ );
+ for node in &report.nodes {
+ let name = [node.vendor_name.as_deref(), node.product_name.as_deref()]
+ .into_iter()
+ .flatten()
+ .collect::<Vec<_>>()
+ .join(" ");
+ println!(
+ "Node {}: {}",
+ node.node_id,
+ if name.is_empty() {
+ "(no cached device info)"
+ } else {
+ &name
+ }
+ );
+ println!(
+ " Last connection: {}",
+ display_instant(node.last_successful_connection)
+ );
+ println!(
+ " Last successful sync: {}",
+ display_instant(node.last_successful_sync)
+ );
+ println!(
+ " Last attempted sync: {}",
+ display_instant(node.last_attempted_sync)
+ );
+ println!(
+ " Most recent error: {}",
+ node.last_error.as_deref().unwrap_or("none")
+ );
+ }
+}
+
+fn render_inspection(nodes: &[InspectOutcome]) {
+ if nodes.is_empty() {
+ println!("No devices are commissioned yet; run \"commission\" to add one.");
+ return;
+ }
+ for outcome in nodes {
+ println!("Node {}", outcome.node_id);
+ if let Some(error) = &outcome.error {
+ println!(" Error: {error}");
+ println!();
+ continue;
+ }
+ println!(
+ " Vendor: {}",
+ outcome.vendor_name.as_deref().unwrap_or("(unknown)")
+ );
+ println!(
+ " Product: {}",
+ outcome.product_name.as_deref().unwrap_or("(unknown)")
+ );
+ if let Some(time_sync) = &outcome.time_sync {
+ println!(" Time Synchronization cluster (endpoint 0):");
+ println!(
+ " features: {:#x}{}",
+ time_sync.feature_map,
+ if time_sync.time_zone_feature {
+ " (timeZone)"
+ } else {
+ ""
+ }
+ );
+ println!(
+ " utcTime: {}",
+ time_sync.utc_time.as_deref().unwrap_or("unset")
+ );
+ println!(" granularity: {}", time_sync.granularity);
+ println!(
+ " dstOffsetListMaxSize: {}",
+ time_sync.dst_offset_list_max_size
+ );
+ }
+ println!(
+ " Our fabric entry: label {:?}, device fabric index {}",
+ outcome.our_fabric_label.as_deref().unwrap_or("(none)"),
+ outcome
+ .our_fabric_index
+ .map(|i| i.to_string())
+ .unwrap_or_else(|| "?".into())
+ );
+ println!();
+ }
+}
+
+fn render_sync(report: &SyncReport) {
+ if !report.host_ntp_synchronized && report.manual_time.is_none() {
+ // The refusal is already on stderr as a warning.
+ return;
+ }
+ if let Some(manual) = &report.manual_time {
+ println!("Manual time set: {manual} (host NTP state not enforced)");
+ }
+ for outcome in &report.nodes {
+ println!("Node {}:", outcome.node_id);
+ if let Some(before) = &outcome.clock_before {
+ println!(" Assessment: {before}");
+ }
+ if let Some(written) = &outcome.utc_time_written {
+ println!(" Time written: {written}");
+ }
+ if let Some(zone) = &outcome.time_zone_written {
+ println!(" Time zone: {zone}");
+ }
+ if outcome.dst_entries_written > 0 {
+ println!(" DST offsets: {} entries", outcome.dst_entries_written);
+ }
+ if let Some(delta) = outcome.delta_after_micros {
+ println!(
+ " Verification: device clock within {} of host after sync",
+ CompactDuration(delta.unsigned_abs())
+ );
+ }
+ match (&outcome.error, outcome.skipped) {
+ (None, _) => println!(" Result: OK"),
+ (Some(reason), true) => println!(" Result: SKIPPED ({reason})"),
+ (Some(error), false) => println!(" Result: FAILED ({error})"),
+ }
+ }
+}
+
+fn render_commissioned(outcome: &CommissionOutcome) {
+ println!("Commissioning summary");
+ println!(" Node ID: {}", outcome.node_id);
+ println!(" Fabric ID: {}", outcome.fabric_id);
+ println!(
+ " Device: {}",
+ [
+ outcome.vendor_name.as_deref(),
+ outcome.product_name.as_deref()
+ ]
+ .into_iter()
+ .flatten()
+ .collect::<Vec<_>>()
+ .join(" ")
+ );
+ println!();
+ println!("The device remains paired with its primary ecosystem; this controller");
+ println!("was added as an additional Matter administrator. Run \"sync\" to");
+ println!("synchronize its clock now.");
+}
+
+fn render_decommission(nodes: &[DecommissionOutcome], remaining: &[String]) {
+ for outcome in nodes {
+ if outcome.success {
+ println!(
+ "Device {} removed this controller's fabric; its primary ecosystem is untouched.",
+ outcome.node_id
+ );
+ } else {
+ println!(
+ "Device {} could NOT be decommissioned: {}",
+ outcome.node_id,
+ outcome.error.as_deref().unwrap_or("unknown error")
+ );
+ }
+ }
+ if remaining.is_empty() {
+ println!(
+ "No devices remain commissioned. The local controller identity remains in the \
+ storage directory; deleting it is now safe."
+ );
+ } else {
+ println!(
+ "{} device(s) remain commissioned: {}",
+ remaining.len(),
+ remaining.join(", ")
+ );
+ }
+}
diff --git a/src/pairing.rs b/src/pairing.rs
new file mode 100644
index 0000000..715d7a8
--- /dev/null
+++ b/src/pairing.rs
@@ -0,0 +1,129 @@
+//! Manual pairing code decoding (Matter Core spec 5.1.4.1).
+//!
+//! An 11-digit (no VID/PID) or 21-digit (with VID/PID) decimal code packs a
+//! 4-bit short discriminator and the 27-bit setup passcode, guarded by a
+//! trailing Verhoeff check digit. QR payloads ("MT:...") are not accepted;
+//! the primary ecosystem always shows the numeric code.
+
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum PairingCodeError {
+ #[error(
+ "QR payloads are not supported; enter the numeric pairing code shown by the primary ecosystem"
+ )]
+ QrPayload,
+ #[error("pairing code must be 11 or 21 digits (got {0})")]
+ BadLength(usize),
+ #[error("pairing code check digit is wrong; re-check the digits")]
+ BadCheckDigit,
+ #[error("pairing code decodes to an invalid setup passcode")]
+ BadPasscode,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Onboarding {
+ pub passcode: u32,
+ /// Upper 4 bits of the 12-bit discriminator, as carried in manual codes.
+ pub short_discriminator: u8,
+}
+
+/// Setup passcodes the spec forbids (trivial/sequential values).
+const INVALID_PASSCODES: [u32; 12] = [
+ 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, 99999999,
+ 12345678, 87654321,
+];
+
+pub fn parse_manual_code(raw: &str) -> Result<Onboarding, PairingCodeError> {
+ let trimmed = raw.trim();
+ if trimmed.starts_with("MT:") {
+ return Err(PairingCodeError::QrPayload);
+ }
+ let digits: Vec<u8> = trimmed
+ .chars()
+ .filter(|c| !c.is_whitespace() && *c != '-')
+ .map(|c| {
+ c.to_digit(10)
+ .map(|d| d as u8)
+ .ok_or(PairingCodeError::BadLength(0))
+ })
+ .collect::<Result<_, _>>()
+ .map_err(|_| PairingCodeError::BadLength(trimmed.len()))?;
+
+ if digits.len() != 11 && digits.len() != 21 {
+ return Err(PairingCodeError::BadLength(digits.len()));
+ }
+
+ let payload: String = digits[..digits.len() - 1]
+ .iter()
+ .map(|d| (b'0' + d) as char)
+ .collect();
+ if verhoeff::calculate(payload.as_str()) != digits[digits.len() - 1] {
+ return Err(PairingCodeError::BadCheckDigit);
+ }
+
+ let d1 = u32::from(digits[0]);
+ let chunk2 = digits[1..6]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+ let chunk3 = digits[6..10]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+
+ // digit 1 = (VID_PID_PRESENT << 2) | (discriminator >> 10)
+ // digits 2-6 = ((discriminator & 0x300) << 6) | (passcode & 0x3FFF)
+ // digits 7-10 = passcode >> 14
+ let short_discriminator = (((d1 & 0x3) << 2) | ((chunk2 >> 14) & 0x3)) as u8;
+ let passcode = (chunk3 << 14) | (chunk2 & 0x3FFF);
+
+ if passcode >= 1 << 27 || INVALID_PASSCODES.contains(&passcode) {
+ return Err(PairingCodeError::BadPasscode);
+ }
+ Ok(Onboarding {
+ passcode,
+ short_discriminator,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decodes_the_canonical_test_code() {
+ // The Matter SDK test onboarding payload: passcode 20202021,
+ // discriminator 3840 (0xF00) => short discriminator 15, manual code
+ // 34970112332.
+ let onboarding = parse_manual_code("34970112332").unwrap();
+ assert_eq!(
+ onboarding,
+ Onboarding {
+ passcode: 20202021,
+ short_discriminator: 15,
+ }
+ );
+ }
+
+ #[test]
+ fn accepts_hyphenated_input() {
+ assert!(parse_manual_code("3497-011-2332").is_ok());
+ }
+
+ #[test]
+ fn rejects_qr_payloads_with_guidance() {
+ assert_eq!(
+ parse_manual_code("MT:Y.K90SO527JA0648G00"),
+ Err(PairingCodeError::QrPayload)
+ );
+ }
+
+ #[test]
+ fn rejects_wrong_lengths_and_check_digits() {
+ assert!(matches!(
+ parse_manual_code("1234"),
+ Err(PairingCodeError::BadLength(_))
+ ));
+ assert_eq!(
+ parse_manual_code("34970112331"),
+ Err(PairingCodeError::BadCheckDigit)
+ );
+ }
+}
diff --git a/src/state.rs b/src/state.rs
new file mode 100644
index 0000000..887ac21
--- /dev/null
+++ b/src/state.rs
@@ -0,0 +1,194 @@
+//! 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")
+}
+
+#[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]);
+ }
+}
diff --git a/src/time.rs b/src/time.rs
new file mode 100644
index 0000000..bedc1fa
--- /dev/null
+++ b/src/time.rs
@@ -0,0 +1,367 @@
+//! Matter epoch conversion and device clock assessment.
+//!
+//! Matter UTC time is microseconds since 2000-01-01T00:00:00Z (the "Matter
+//! epoch"), not the Unix epoch. `jiff::Timestamp` is the lingua franca
+//! everywhere else in this program; [`MatterMicros`] exists only at the wire
+//! boundary. Unlike matter.js, rs-matter passes spec values through
+//! unconverted, so no Unix-epoch API shift exists here.
+
+use std::fmt;
+
+use jiff::Timestamp;
+
+pub const MATTER_EPOCH_UNIX_SECONDS: i64 = 946_684_800;
+const MATTER_EPOCH_UNIX_MICROS: i64 = MATTER_EPOCH_UNIX_SECONDS * 1_000_000;
+
+/// Microseconds since the Matter epoch, as carried in Time Synchronization
+/// cluster attributes and commands.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct MatterMicros(pub u64);
+
+impl MatterMicros {
+ pub fn now() -> Self {
+ Self::from_timestamp(Timestamp::now())
+ }
+
+ /// Panics on pre-2000 timestamps, which cannot be represented on the
+ /// wire and cannot arise from a running host clock we already required
+ /// to be NTP-synchronized.
+ pub fn from_timestamp(at: Timestamp) -> Self {
+ let micros = at.as_microsecond() - MATTER_EPOCH_UNIX_MICROS;
+ Self(u64::try_from(micros).expect("timestamp precedes the Matter epoch"))
+ }
+
+ pub fn to_timestamp(self) -> Timestamp {
+ Timestamp::from_microsecond(self.0 as i64 + MATTER_EPOCH_UNIX_MICROS)
+ .expect("Matter timestamp out of jiff range")
+ }
+
+ /// Signed delta `self - other` in microseconds.
+ pub fn delta_micros(self, other: Self) -> i64 {
+ self.0 as i64 - other.0 as i64
+ }
+}
+
+impl fmt::Display for MatterMicros {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.to_timestamp().fmt(f)
+ }
+}
+
+/// A device is treated as epoch-confused when its clock delta lands within
+/// this window of the exact Unix/Matter epoch distance: firmware that encodes
+/// Unix-epoch values on the wire reads as ~30 years ahead after decoding. A
+/// week comfortably covers any real drift while remaining astronomically far
+/// from every honest delta.
+const EPOCH_SHIFT_DETECTION_WINDOW_MICROS: i64 = 7 * 86_400 * 1_000_000;
+
+/// How the device's reported clock relates to the host clock.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ClockAssessment {
+ /// The device lost its clock (null utcTime, e.g. after a power outage).
+ Unset,
+ /// The device reported a time `delta_micros` away from the host's.
+ Offset {
+ /// Raw reported delta (device minus host), exact.
+ delta_micros: i64,
+ /// True when the delta is the Unix/Matter epoch distance: the device
+ /// firmware encodes the wrong epoch on the wire (off-spec).
+ epoch_shifted: bool,
+ },
+}
+
+impl ClockAssessment {
+ pub fn compare(device: Option<MatterMicros>, host: MatterMicros) -> Self {
+ let Some(device) = device else {
+ return Self::Unset;
+ };
+ let delta_micros = device.delta_micros(host);
+ let shift_error = delta_micros - MATTER_EPOCH_UNIX_MICROS;
+ Self::Offset {
+ delta_micros,
+ epoch_shifted: shift_error.abs() <= EPOCH_SHIFT_DETECTION_WINDOW_MICROS,
+ }
+ }
+
+ /// The device's real clock error: the raw delta with any detected epoch
+ /// shift folded out. `None` when the clock was unset.
+ pub fn effective_delta_micros(&self) -> Option<i64> {
+ match *self {
+ Self::Unset => None,
+ Self::Offset {
+ delta_micros,
+ epoch_shifted,
+ } => Some(if epoch_shifted {
+ delta_micros - MATTER_EPOCH_UNIX_MICROS
+ } else {
+ delta_micros
+ }),
+ }
+ }
+
+ pub fn is_epoch_shifted(&self) -> bool {
+ matches!(
+ self,
+ Self::Offset {
+ epoch_shifted: true,
+ ..
+ }
+ )
+ }
+}
+
+impl fmt::Display for ClockAssessment {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Unset => f.write_str("device clock was unset"),
+ Self::Offset { epoch_shifted, .. } => {
+ let effective = self
+ .effective_delta_micros()
+ .expect("Offset always has a delta");
+ if *epoch_shifted {
+ write!(
+ f,
+ "device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was {}",
+ DescribeDelta(effective)
+ )
+ } else {
+ write!(f, "device clock was {}", DescribeDelta(effective))
+ }
+ }
+ }
+ }
+}
+
+/// "within 1s of host time (412ms behind)" / "1m 23s ahead" for a signed delta.
+struct DescribeDelta(i64);
+
+impl fmt::Display for DescribeDelta {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let magnitude = self.0.unsigned_abs();
+ let direction = if self.0 < 0 { "behind" } else { "ahead" };
+ if magnitude < 1_000_000 {
+ write!(
+ f,
+ "within 1s of host time ({} {direction})",
+ CompactDuration(magnitude)
+ )
+ } else {
+ write!(f, "{} {direction}", CompactDuration(magnitude))
+ }
+ }
+}
+
+/// Compact human duration: 412ms, 3.2s, 1m 23s, 1d 1h 1m.
+pub struct CompactDuration(pub u64);
+
+impl fmt::Display for CompactDuration {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let micros = self.0;
+ if micros < 1_000 {
+ return write!(f, "{micros}us");
+ }
+ if micros < 1_000_000 {
+ return write!(f, "{}ms", micros / 1_000);
+ }
+ let total_seconds = micros / 1_000_000;
+ if total_seconds < 60 {
+ let tenths = (micros % 1_000_000) / 100_000;
+ return if tenths == 0 {
+ write!(f, "{total_seconds}s")
+ } else {
+ write!(f, "{total_seconds}.{tenths}s")
+ };
+ }
+ let days = total_seconds / 86_400;
+ 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"));
+ }
+ f.write_str(&parts.join(" "))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ts(s: &str) -> Timestamp {
+ s.parse().unwrap()
+ }
+
+ #[test]
+ fn matter_epoch_zero_is_year_2000() {
+ assert_eq!(
+ MatterMicros::from_timestamp(ts("2000-01-01T00:00:00Z")).0,
+ 0
+ );
+ assert_eq!(MatterMicros(0).to_timestamp(), ts("2000-01-01T00:00:00Z"));
+ assert_eq!(
+ MatterMicros(1_000_000).to_timestamp(),
+ ts("2000-01-01T00:00:01Z")
+ );
+ }
+
+ #[test]
+ fn conversion_round_trips_current_dates() {
+ let now = ts("2026-07-26T20:00:00.123456Z");
+ assert_eq!(MatterMicros::from_timestamp(now).to_timestamp(), now);
+ }
+
+ #[test]
+ fn compact_duration_scales_units() {
+ let cases = [
+ (0, "0us"),
+ (999, "999us"),
+ (412_000, "412ms"),
+ (3_200_000, "3.2s"),
+ (59_000_000, "59s"),
+ (125_000_000, "2m 5s"),
+ (3_840_000_000, "1h 4m"),
+ (90_061_000_000, "1d 1h 1m"),
+ ];
+ for (micros, expected) in cases {
+ assert_eq!(CompactDuration(micros).to_string(), expected);
+ }
+ }
+
+ #[test]
+ fn assessment_reports_unset_clock() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let assessment = ClockAssessment::compare(None, host);
+ assert_eq!(assessment, ClockAssessment::Unset);
+ assert_eq!(assessment.to_string(), "device clock was unset");
+ }
+
+ #[test]
+ fn assessment_reports_direction_and_magnitude() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let behind = ClockAssessment::compare(Some(MatterMicros(host.0 - 83_000_000)), host);
+ assert_eq!(behind.to_string(), "device clock was 1m 23s behind");
+ assert_eq!(behind.effective_delta_micros(), Some(-83_000_000));
+
+ let ahead = ClockAssessment::compare(Some(MatterMicros(host.0 + 5_500_000)), host);
+ assert_eq!(ahead.to_string(), "device clock was 5.5s ahead");
+
+ let close = ClockAssessment::compare(Some(MatterMicros(host.0 - 412_000)), host);
+ assert_eq!(
+ close.to_string(),
+ "device clock was within 1s of host time (412ms behind)"
+ );
+ }
+
+ #[test]
+ fn assessment_detects_wrong_epoch_encoding() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let shift = (MATTER_EPOCH_UNIX_SECONDS * 1_000_000) as u64;
+ let wrong = ClockAssessment::compare(Some(MatterMicros(host.0 + shift + 1_400_000)), host);
+ assert!(wrong.is_epoch_shifted());
+ assert_eq!(wrong.effective_delta_micros(), Some(1_400_000));
+ assert_eq!(
+ wrong.to_string(),
+ "device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was 1.4s ahead"
+ );
+ }
+
+ #[test]
+ fn assessment_does_not_fold_out_ordinary_large_errors() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let shift = (MATTER_EPOCH_UNIX_SECONDS * 1_000_000) as u64;
+ let month = 30 * 86_400 * 1_000_000;
+ let broken = ClockAssessment::compare(Some(MatterMicros(host.0 + shift + month)), host);
+ assert!(!broken.is_epoch_shifted());
+ }
+}
+
+/// Parses an operator-supplied wall-clock time: "16:35", "4:35p", "4:35pm",
+/// each with an optional ":ss". Meridiem suffixes imply 12-hour form.
+pub fn parse_wall_clock(input: &str) -> Result<jiff::civil::Time, String> {
+ let lowered = input.trim().to_ascii_lowercase();
+ let (digits, meridiem) = if let Some(rest) = lowered
+ .strip_suffix("am")
+ .or_else(|| lowered.strip_suffix('a'))
+ {
+ (rest.trim_end(), Some(false))
+ } else if let Some(rest) = lowered
+ .strip_suffix("pm")
+ .or_else(|| lowered.strip_suffix('p'))
+ {
+ (rest.trim_end(), Some(true))
+ } else {
+ (lowered.as_str(), None)
+ };
+
+ let parts: Vec<&str> = digits.split(':').collect();
+ if !(2..=3).contains(&parts.len()) {
+ return Err(format!(
+ "cannot parse {input:?} as a time (expected HH:MM or HH:MM:SS, optionally with am/pm)"
+ ));
+ }
+ let numbers: Vec<u8> = parts
+ .iter()
+ .map(|p| {
+ p.parse()
+ .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 };
+ }
+ None if hour > 23 => return Err(format!("hour in {input:?} must be 0-23")),
+ None => {}
+ }
+ jiff::civil::Time::new(hour as i8, minute as i8, second as i8, 0)
+ .map_err(|e| format!("invalid time {input:?}: {e}"))
+}
+
+#[cfg(test)]
+mod wall_clock_tests {
+ use super::parse_wall_clock;
+
+ #[test]
+ fn parses_12_and_24_hour_forms() {
+ let cases = [
+ ("16:35", (16, 35, 0)),
+ ("4:35p", (16, 35, 0)),
+ ("4:35pm", (16, 35, 0)),
+ ("4:35a", (4, 35, 0)),
+ ("12:00am", (0, 0, 0)),
+ ("12:15PM", (12, 15, 0)),
+ ("16:35:20", (16, 35, 20)),
+ ("07:05", (7, 5, 0)),
+ ];
+ for (input, (h, m, s)) in cases {
+ let time = parse_wall_clock(input).unwrap();
+ assert_eq!(
+ (time.hour(), time.minute(), time.second()),
+ (h, m, s),
+ "{input}"
+ );
+ }
+ }
+
+ #[test]
+ fn rejects_nonsense() {
+ for input in ["25:00", "13:00pm", "4", "4:60", "0:00am", "banana"] {
+ assert!(parse_wall_clock(input).is_err(), "accepted {input:?}");
+ }
+ }
+}
diff --git a/src/tz.rs b/src/tz.rs
new file mode 100644
index 0000000..2820c59
--- /dev/null
+++ b/src/tz.rs
@@ -0,0 +1,268 @@
+//! IANA time-zone helpers: Matter TimeZone/DSTOffset structure generation
+//! and offset introspection for status output.
+//!
+//! jiff exposes the real tzdb transition table, so upcoming DST changes are
+//! read directly instead of probed for (the TypeScript implementation had to
+//! binary-search ICU offsets; here `TimeZone::following` is exact by
+//! construction).
+
+use jiff::Timestamp;
+use jiff::tz::TimeZone;
+
+use crate::time::MatterMicros;
+
+/// Matter TimeZoneStruct: the zone's standard offset, DST carried separately.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MatterTimeZoneEntry {
+ /// Standard (non-DST) UTC offset in seconds.
+ pub offset_seconds: i32,
+ /// Matter-epoch microseconds at which the entry takes effect; 0 = always.
+ pub valid_at: MatterMicros,
+ pub name: String,
+}
+
+/// Matter DSTOffsetStruct: one DST period, added on top of the TimeZone offset.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MatterDstOffsetEntry {
+ pub offset_seconds: i32,
+ pub valid_starting: MatterMicros,
+ /// None = valid until further notice (last entry only).
+ pub valid_until: Option<MatterMicros>,
+}
+
+/// The next instant the zone's UTC offset changes, with the offsets on both
+/// sides. None for fixed-offset zones.
+pub fn next_offset_transition(tz: &TimeZone, from: Timestamp) -> Option<(Timestamp, i32, i32)> {
+ let before = tz.to_offset(from).seconds();
+ tz.following(from)
+ .map(|transition| (transition.timestamp(), transition.offset().seconds()))
+ .find(|&(_, offset)| offset != before)
+ .map(|(at, after)| (at, before, after))
+}
+
+/// The zone's standard (non-DST) UTC offset in seconds: the smaller of the
+/// mid-January and mid-July offsets of the year. DST increases the offset in
+/// every zone as consumed here (including Europe/Dublin, which the tzdb
+/// models with a negative SAVE but presents as +00:00 winter / +01:00 summer).
+pub fn standard_offset_seconds(tz: &TimeZone, at: Timestamp) -> i32 {
+ let year = at.to_zoned(TimeZone::UTC).year();
+ let probe = |month: i8| -> i32 {
+ let date = jiff::civil::date(year, month, 15).at(0, 0, 0, 0);
+ let ts = date
+ .to_zoned(TimeZone::UTC)
+ .expect("UTC has no gaps")
+ .timestamp();
+ tz.to_offset(ts).seconds()
+ };
+ probe(1).min(probe(7))
+}
+
+/// The TimeZone list for SetTimeZone: a single entry carrying the zone's
+/// standard offset and IANA name, valid from the beginning of time.
+pub fn build_time_zone_list(tz: &TimeZone, name: &str, at: Timestamp) -> Vec<MatterTimeZoneEntry> {
+ vec![MatterTimeZoneEntry {
+ offset_seconds: standard_offset_seconds(tz, at),
+ valid_at: MatterMicros(0),
+ name: name.chars().take(64).collect(),
+ }]
+}
+
+/// The DSTOffset list for SetDstOffset: the DST state in effect at `from`
+/// followed by upcoming transitions, at most `max_entries` entries (the
+/// device's DSTOffsetListMaxSize; spec minimum 1). Entries carry concrete
+/// valid-until bounds where a next transition is known, so an unrefreshed
+/// device falls back to standard time rather than trusting stale DST; the
+/// periodic sync refreshes the list long before it expires. Zones without
+/// transitions yield a single open-ended entry.
+pub fn build_dst_offset_list(
+ tz: &TimeZone,
+ max_entries: usize,
+ from: Timestamp,
+) -> Vec<MatterDstOffsetEntry> {
+ let limit = max_entries.max(1);
+ let standard = standard_offset_seconds(tz, from);
+ let mut entries = Vec::with_capacity(limit);
+
+ let mut current_offset = tz.to_offset(from).seconds();
+ let mut valid_starting = MatterMicros(0);
+ let mut transitions = tz
+ .following(from)
+ .map(|transition| (transition.timestamp(), transition.offset().seconds()));
+
+ while entries.len() < limit {
+ // Skip tzdb transitions that don't change the offset (abbreviation or
+ // rule bookkeeping only); they are not DST boundaries.
+ let next = transitions.find(|&(_, offset)| offset != current_offset);
+ match next {
+ Some((at, offset_after)) => {
+ let until = MatterMicros::from_timestamp(at);
+ entries.push(MatterDstOffsetEntry {
+ offset_seconds: current_offset - standard,
+ valid_starting,
+ valid_until: Some(until),
+ });
+ valid_starting = until;
+ current_offset = offset_after;
+ }
+ None => {
+ entries.push(MatterDstOffsetEntry {
+ offset_seconds: current_offset - standard,
+ valid_starting,
+ valid_until: None,
+ });
+ break;
+ }
+ }
+ }
+ entries
+}
+
+/// "UTC-05:00" style rendering for logs and status output.
+pub fn format_utc_offset(offset_seconds: i32) -> String {
+ let sign = if offset_seconds < 0 { '-' } else { '+' };
+ let magnitude = offset_seconds.unsigned_abs();
+ format!(
+ "UTC{sign}{:02}:{:02}",
+ magnitude / 3600,
+ (magnitude % 3600) / 60
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tz(name: &str) -> TimeZone {
+ TimeZone::get(name).unwrap()
+ }
+
+ fn ts(s: &str) -> Timestamp {
+ s.parse().unwrap()
+ }
+
+ fn matter(s: &str) -> MatterMicros {
+ MatterMicros::from_timestamp(ts(s))
+ }
+
+ // The two Chicago transitions after 2026-07-26.
+ fn fall_2026() -> MatterMicros {
+ matter("2026-11-01T07:00:00Z")
+ }
+ fn spring_2027() -> MatterMicros {
+ matter("2027-03-14T08:00:00Z")
+ }
+
+ #[test]
+ fn standard_offset_ignores_season_and_hemisphere() {
+ let chicago = tz("America/Chicago");
+ assert_eq!(
+ standard_offset_seconds(&chicago, ts("2026-07-15T00:00:00Z")),
+ -6 * 3600
+ );
+ assert_eq!(
+ standard_offset_seconds(&chicago, ts("2026-01-15T00:00:00Z")),
+ -6 * 3600
+ );
+ // Sydney: AEST UTC+10 standard, AEDT UTC+11 in southern summer.
+ assert_eq!(
+ standard_offset_seconds(&tz("Australia/Sydney"), ts("2026-01-15T00:00:00Z")),
+ 10 * 3600
+ );
+ assert_eq!(
+ standard_offset_seconds(&tz("UTC"), ts("2026-07-15T00:00:00Z")),
+ 0
+ );
+ assert_eq!(
+ standard_offset_seconds(&tz("Asia/Kolkata"), ts("2026-07-15T00:00:00Z")),
+ (5.5 * 3600.0) as i32
+ );
+ }
+
+ #[test]
+ fn finds_exact_chicago_transitions() {
+ let chicago = tz("America/Chicago");
+ let (at, before, after) =
+ next_offset_transition(&chicago, ts("2026-07-15T00:00:00Z")).unwrap();
+ // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC.
+ assert_eq!(at, ts("2026-11-01T07:00:00Z"));
+ assert_eq!((before, after), (-5 * 3600, -6 * 3600));
+ assert!(next_offset_transition(&tz("UTC"), ts("2026-01-15T00:00:00Z")).is_none());
+ }
+
+ #[test]
+ fn time_zone_list_is_single_standard_entry() {
+ let list = build_time_zone_list(
+ &tz("America/Chicago"),
+ "America/Chicago",
+ ts("2026-07-26T00:00:00Z"),
+ );
+ assert_eq!(
+ list,
+ vec![MatterTimeZoneEntry {
+ offset_seconds: -6 * 3600,
+ valid_at: MatterMicros(0),
+ name: "America/Chicago".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn dst_list_covers_active_period_plus_next() {
+ let list = build_dst_offset_list(&tz("America/Chicago"), 2, ts("2026-07-26T00:00:00Z"));
+ assert_eq!(
+ list,
+ vec![
+ MatterDstOffsetEntry {
+ offset_seconds: 3600,
+ valid_starting: MatterMicros(0),
+ valid_until: Some(fall_2026()),
+ },
+ MatterDstOffsetEntry {
+ offset_seconds: 0,
+ valid_starting: fall_2026(),
+ valid_until: Some(spring_2027()),
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn dst_list_respects_device_capacity() {
+ let list = build_dst_offset_list(&tz("America/Chicago"), 1, ts("2026-07-26T00:00:00Z"));
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].valid_until, Some(fall_2026()));
+ }
+
+ #[test]
+ fn dst_list_is_single_open_ended_zero_for_fixed_zones() {
+ for name in ["UTC", "Asia/Kolkata"] {
+ let list = build_dst_offset_list(&tz(name), 2, ts("2026-07-26T00:00:00Z"));
+ assert_eq!(
+ list,
+ vec![MatterDstOffsetEntry {
+ offset_seconds: 0,
+ valid_starting: MatterMicros(0),
+ valid_until: None,
+ }],
+ "zone {name}"
+ );
+ }
+ }
+
+ #[test]
+ fn dst_list_starts_from_standard_time_in_winter() {
+ let list = build_dst_offset_list(&tz("America/Chicago"), 2, ts("2026-01-15T00:00:00Z"));
+ let spring_2026 = matter("2026-03-08T08:00:00Z");
+ assert_eq!(list[0].offset_seconds, 0);
+ assert_eq!(list[0].valid_until, Some(spring_2026));
+ assert_eq!(list[1].offset_seconds, 3600);
+ assert_eq!(list[1].valid_starting, spring_2026);
+ }
+
+ #[test]
+ fn formats_utc_offsets() {
+ assert_eq!(format_utc_offset(-6 * 3600), "UTC-06:00");
+ assert_eq!(format_utc_offset(19_800), "UTC+05:30");
+ assert_eq!(format_utc_offset(0), "UTC+00:00");
+ }
+}