diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..50723a3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: Format, lint & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test + run: cargo test + + cross: + name: Cross-compile ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + # Pi Zero W + - arm-unknown-linux-musleabihf + # Pi Zero 2 W + - aarch64-unknown-linux-musl + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + # Prebuilt binary — `cargo install cross` compiles from source and takes + # minutes on every run. + - uses: taiki-e/install-action@cross + + - name: Build + run: cross build --release --target ${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27ae334..20975b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,19 @@ on: push: tags: - "v*" + workflow_dispatch: permissions: contents: write +env: + CARGO_TERM_COLOR: always + jobs: release: strategy: + # One broken target should not hide the others. + fail-fast: false matrix: include: # Linux x86_64 (musl) @@ -38,11 +44,18 @@ jobs: steps: - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + # Prebuilt binary — `cargo install cross` compiles from source and adds + # minutes to every job. - name: Install cross if: matrix.build == 'cross' - run: cargo install cross + uses: taiki-e/install-action@cross - name: Install target if: matrix.build == 'cargo' @@ -61,17 +74,18 @@ jobs: fi shell: bash - - name: Strip binary - if: runner.os != 'Windows' && matrix.build == 'cargo' - run: strip target/${{ matrix.target }}/release/${{ matrix.binary }} + # No explicit strip step: [profile.release] already sets strip = true. - - name: Rename binary + - name: Rename binary and checksum it shell: bash run: | cp target/${{ matrix.target }}/release/${{ matrix.binary }} \ ${{ matrix.asset }} + sha256sum ${{ matrix.asset }} > ${{ matrix.asset }}.sha256 - name: Upload to GitHub Release uses: softprops/action-gh-release@v2 with: - files: ${{ matrix.asset }} + files: | + ${{ matrix.asset }} + ${{ matrix.asset }}.sha256 diff --git a/.gitignore b/.gitignore index f8d3b14..07cfa32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # Generated files /target/ -/Cargo.lock *.bk *.bak.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..54ca35c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1131 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "bluer" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af68112f5c60196495c8b0eea68349817855f565df5b04b2477916d09fb1a901" +dependencies = [ + "custom_debug", + "dbus", + "dbus-crossroads", + "dbus-tokio", + "displaydoc", + "futures", + "hex", + "lazy_static", + "libc", + "log", + "macaddr", + "nix", + "num-derive", + "num-traits", + "pin-project", + "serde", + "serde_json", + "strum", + "tokio", + "tokio-stream", + "uuid", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[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 = "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 = "custom_debug" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da7d1ad9567b3e11e877f1d7a0fa0360f04162f94965fc4448fbed41a65298e" +dependencies = [ + "custom_debug_derive", +] + +[[package]] +name = "custom_debug_derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a707ceda8652f6c7624f2be725652e9524c815bf3b9d55a0b2320be2303f9c11" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + +[[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", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[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 = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macaddr" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "mitempr" +version = "0.1.0" +dependencies = [ + "bluer", + "clap", + "env_logger", + "futures", + "libdbus-sys", + "log", + "serde", + "serde_json", + "tokio", + "toml", + "uuid", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[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 = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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 = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[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 = "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 = "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 = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[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 = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[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 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[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 = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[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 index 88dcddb..498504f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,16 +2,34 @@ name = "mitempr" version = "0.1.0" edition = "2024" +description = "Read environmental data from Bluetooth sensors (BTHome v2, PVVX, LYWSDCGQ)" +repository = "https://github.com/mreymann/mitempr" +readme = "README.md" +keywords = ["bluetooth", "ble", "bthome", "sensor", "xiaomi"] +categories = ["command-line-utilities", "embedded"] [dependencies] -tokio = { version = "1.48", features = ["full"] } -uuid = { version = "1.4", features = ["v4"] } -anyhow = "1.0" +# Only the runtime pieces actually used. "full" additionally pulls in fs, net, +# process and io-util, which cost compile time and binary size. +tokio = { version = "1.48", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] } +uuid = "1.4" libdbus-sys = { version = "0.2", features = ["vendored"] } # "vendored" is needed for cross compilation! -bluer = { version = "0.17", features = ["bluetoothd", "full"] } +# "bluetoothd" is the D-Bus client API this tool uses; "full" would also build +# the mesh, l2cap and rfcomm stacks, none of which are referenced here. +bluer = { version = "0.17", features = ["bluetoothd"] } futures = "0.3" -hex = "0.4" # <-- Add this for clean data printing clap = { version = "4", features = ["derive"] } +log = "0.4" +# Without default features env_logger skips its regex-based message filtering, +# which drops the regex and aho-corasick dependencies entirely. RUST_LOG still +# works; only filtering by message body does not. +env_logger = { version = "0.11", default-features = false, features = [ + "auto-color", + "humantime", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.9" [profile.release] opt-level = 3 diff --git a/Cross.toml b/Cross.toml deleted file mode 100644 index 0b34b10..0000000 --- a/Cross.toml +++ /dev/null @@ -1,5 +0,0 @@ -#[target.arm-unknown-linux-gnueabihf] -#dockerfile = "Dockerfile.arm-unknown-linux-gnueabihf" - -#[target.arm-unknown-linux-musleabihf] -#dockerfile = "Dockerfile.arm-unknown-linux-musleabihf" diff --git a/README.md b/README.md index 65e9e17..fd9ac1f 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,70 @@ Strongly inspired by [Mitemperature2](https://github.com/JsBergbau/MiTemperature - nicely cross compiles to armv6 (Pi Zero W), armv7 (Pi Zero W 2) - continuous scanning now works! +## Usage + +``` +mitempr [--config PATH] [--only-known] [--min-rssi DBM] + [--format text|json] [-v|-vv] [-q] + [--watchdog SECS] [--cooldown SECS] +``` + +`--format text` (the default) writes one line per reading through the logger: + +``` +[2026-07-31T10:12:44Z INFO mitempr::output] A4:C1:38:A0:7B:03 [pvvx] LYWSD03MMC -67 dBm | 22.90 C 64.25 %RH 2.333 V 16 % battery +``` + +`--format json` writes one JSON object per reading straight to stdout, so it can +be piped somewhere useful. Measurements a sensor does not report are left out +rather than sent as `null`: + +```console +$ mitempr --format json | jq -c '{address, temperature_celsius}' +{"address":"A4:C1:38:A0:7B:03","temperature_celsius":22.9} +``` + +Log verbosity: `-v` adds why a payload could not be decoded, `-vv` adds the raw +service-data bytes, `-q` leaves only warnings and errors. `RUST_LOG` overrides +all of them. Readings written with `--format json` go to stdout and are never +suppressed by `-q`. + +`--watchdog` restarts discovery when no reading has arrived for that many +seconds, which recovers a wedged adapter; `--cooldown` is the pause before the +restart. + +## Configuration + +Without a configuration file every decodable sensor is reported under whatever +name it advertises. `--config` adds names, calibration offsets and filtering — +see [`mitempr.toml.example`](mitempr.toml.example): + +```toml +[general] +only_known = true # ignore devices with no [[sensor]] block below +min_rssi = -90 # ignore advertisements weaker than this + +[[sensor]] +mac = "A4:C1:38:00:11:22" +name = "Living Room" +temperature_offset = -0.3 # added to every reading from this sensor +humidity_offset = 1.5 +``` + +A configured `name` replaces the advertised one, so readings are labelled the +way you think about the room rather than `LYWSD03MMC`. `--only-known` and +`--min-rssi` on the command line override the `[general]` section, and +`only_known` is checked before any properties are read, so ignored devices cost +nothing but the event. + +Unknown keys are rejected rather than ignored, so a typo like `temp_offset` +tells you about itself instead of silently doing nothing. + ## TODOs - - get this darn thing to be more responsive (#bluez) - also decode **encrypted** data - URL callback to Prometheus Push Gateway - call external scripts - - define sensors in a config file & filter defined sensors - - add flags and options to binary - and many more things to fiddle with ;-) ## Cross compiling diff --git a/mitempr.toml.example b/mitempr.toml.example new file mode 100644 index 0000000..72be745 --- /dev/null +++ b/mitempr.toml.example @@ -0,0 +1,31 @@ +# Example configuration for mitempr. Pass it with --config /etc/mitempr.toml. +# +# Everything here is optional: without a configuration file mitempr reports +# every sensor it can decode, using whatever name the device advertises. + +[general] +# Report only the sensors listed below and ignore every other device. Useful in +# a block of flats where the neighbours' thermometers are in range too. +# Also saves a D-Bus round-trip per ignored advertisement. +only_known = true + +# Ignore advertisements weaker than this, in dBm. A reading from a sensor at the +# edge of range is often a garbled one. Advertisements whose RSSI BlueZ does not +# report are always accepted. +min_rssi = -90 + +[[sensor]] +mac = "A4:C1:38:00:11:22" +name = "Living Room" +# Added to every reading from this sensor, to line it up with a reference +# thermometer. Both default to 0. +temperature_offset = -0.3 +humidity_offset = 1.5 + +[[sensor]] +mac = "A4:C1:38:AA:BB:CC" +name = "Balcony" + +# A sensor needs nothing but its MAC address to be "known". +[[sensor]] +mac = "58:2D:34:11:22:33" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..19f9e02 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,433 @@ +//! Optional TOML configuration: naming sensors, calibrating them and ignoring +//! everything else. + +use crate::decoder::SensorData; +use bluer::Address; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::{fmt, fs, io}; + +/// The file as it is written on disk. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + #[serde(default)] + general: General, + /// `[[sensor]]` blocks. + #[serde(default, rename = "sensor")] + sensors: Vec, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct General { + /// Ignore any sensor that has no `[[sensor]]` block. + #[serde(default)] + only_known: bool, + /// Ignore advertisements weaker than this, in dBm. + min_rssi: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SensorEntry { + mac: String, + name: Option, + /// Added to every temperature reading from this sensor, in degrees Celsius. + #[serde(default)] + temperature_offset: f32, + /// Added to every humidity reading from this sensor, in percent. + #[serde(default)] + humidity_offset: f32, +} + +/// What is known about one configured sensor. +#[derive(Debug, Default, PartialEq)] +pub struct SensorSettings { + pub name: Option, + pub temperature_offset: f32, + pub humidity_offset: f32, +} + +impl SensorSettings { + /// Apply this sensor's calibration offsets in place. + pub fn calibrate(&self, data: &mut SensorData) { + if let Some(temperature) = data.temperature.as_mut() { + *temperature += self.temperature_offset; + } + if let Some(humidity) = data.humidity.as_mut() { + *humidity += self.humidity_offset; + } + } +} + +/// The resolved configuration used while scanning. +#[derive(Debug, Default)] +pub struct Config { + sensors: HashMap, + only_known: bool, + min_rssi: Option, +} + +impl Config { + /// Read and validate a configuration file. + pub fn load(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|source| ConfigError::Read { + path: path.to_path_buf(), + source, + })?; + + let file: File = toml::from_str(&text).map_err(|source| ConfigError::Parse { + path: path.to_path_buf(), + source: Box::new(source), + })?; + + let mut sensors = HashMap::with_capacity(file.sensors.len()); + for entry in file.sensors { + let address = parse_address(&entry.mac)?; + let settings = SensorSettings { + name: entry.name, + temperature_offset: entry.temperature_offset, + humidity_offset: entry.humidity_offset, + }; + if sensors.insert(address, settings).is_some() { + return Err(ConfigError::DuplicateSensor(entry.mac)); + } + } + + Ok(Self { + sensors, + only_known: file.general.only_known, + min_rssi: file.general.min_rssi, + }) + } + + /// Turn on `only_known` regardless of what the file said. + pub fn set_only_known(&mut self) { + self.only_known = true; + } + + /// Override the file's `min_rssi`. + pub fn set_min_rssi(&mut self, min_rssi: i16) { + self.min_rssi = Some(min_rssi); + } + + pub fn configured_sensors(&self) -> usize { + self.sensors.len() + } + + pub fn only_known(&self) -> bool { + self.only_known + } + + /// Whether this device is worth the D-Bus round-trip needed to read its + /// properties. Checked before fetching them, so filtered-out devices cost + /// nothing but the event. + pub fn accepts_address(&self, address: Address) -> bool { + !self.only_known || self.sensors.contains_key(&address) + } + + /// Whether an advertisement is strong enough to report. An unknown RSSI is + /// accepted: absent is not the same as weak. + pub fn accepts_rssi(&self, rssi: Option) -> bool { + match (self.min_rssi, rssi) { + (Some(minimum), Some(actual)) => actual >= minimum, + _ => true, + } + } + + pub fn settings(&self, address: Address) -> Option<&SensorSettings> { + self.sensors.get(&address) + } +} + +/// Parse `A4:C1:38:00:11:22`. +fn parse_address(value: &str) -> Result { + let invalid = || ConfigError::InvalidAddress(value.to_owned()); + + let mut bytes = [0u8; 6]; + let mut groups = value.split(':'); + for byte in &mut bytes { + let group = groups.next().ok_or_else(invalid)?; + if group.len() != 2 { + return Err(invalid()); + } + *byte = u8::from_str_radix(group, 16).map_err(|_| invalid())?; + } + if groups.next().is_some() { + return Err(invalid()); + } + + Ok(Address::new(bytes)) +} + +#[derive(Debug)] +pub enum ConfigError { + Read { + path: PathBuf, + source: io::Error, + }, + // Boxed because toml::de::Error is large and this variant is the rare path. + Parse { + path: PathBuf, + source: Box, + }, + InvalidAddress(String), + DuplicateSensor(String), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read { path, source } => write!(f, "cannot read {}: {source}", path.display()), + Self::Parse { path, source } => write!(f, "cannot parse {}: {source}", path.display()), + Self::InvalidAddress(value) => { + write!(f, "{value:?} is not a MAC address like A4:C1:38:00:11:22") + } + Self::DuplicateSensor(value) => write!(f, "{value} is configured more than once"), + } + } +} + +impl std::error::Error for ConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read { source, .. } => Some(source), + Self::Parse { source, .. } => Some(source), + Self::InvalidAddress(_) | Self::DuplicateSensor(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn living_room() -> Address { + Address::new([0xA4, 0xC1, 0x38, 0x00, 0x11, 0x22]) + } + + fn unknown() -> Address { + Address::new([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + } + + /// `Config::load` takes a path, so tests need a real file. + fn load(toml: &str) -> Result { + let path = std::env::temp_dir().join(format!( + "mitempr-config-test-{}-{:?}.toml", + std::process::id(), + std::thread::current().id() + )); + let mut file = fs::File::create(&path).expect("temp file should be creatable"); + file.write_all(toml.as_bytes()) + .expect("temp file should be writable"); + drop(file); + + let result = Config::load(&path); + let _ = fs::remove_file(&path); + result + } + + #[test] + fn an_empty_file_accepts_everything() { + let config = load("").expect("an empty config is valid"); + + assert_eq!(config.configured_sensors(), 0); + assert!(config.accepts_address(unknown())); + assert!(config.accepts_rssi(Some(-120))); + } + + #[test] + fn sensors_are_looked_up_by_address() { + let config = load( + r#" + [[sensor]] + mac = "A4:C1:38:00:11:22" + name = "Living Room" + temperature_offset = -0.3 + humidity_offset = 1.5 + "#, + ) + .expect("config should parse"); + + let settings = config + .settings(living_room()) + .expect("sensor should be found"); + assert_eq!(settings.name.as_deref(), Some("Living Room")); + assert!((settings.temperature_offset - -0.3).abs() < 0.0001); + assert!((settings.humidity_offset - 1.5).abs() < 0.0001); + assert!(config.settings(unknown()).is_none()); + } + + #[test] + fn a_sensor_needs_nothing_but_a_mac() { + let config = load( + r#" + [[sensor]] + mac = "A4:C1:38:00:11:22" + "#, + ) + .expect("config should parse"); + + let settings = config + .settings(living_room()) + .expect("sensor should be found"); + assert_eq!(settings, &SensorSettings::default()); + } + + #[test] + fn only_known_rejects_unlisted_sensors() { + let config = load( + r#" + [general] + only_known = true + + [[sensor]] + mac = "A4:C1:38:00:11:22" + "#, + ) + .expect("config should parse"); + + assert!(config.accepts_address(living_room())); + assert!(!config.accepts_address(unknown())); + } + + #[test] + fn min_rssi_rejects_weak_advertisements_but_not_missing_ones() { + let config = load( + r#" + [general] + min_rssi = -90 + "#, + ) + .expect("config should parse"); + + assert!(config.accepts_rssi(Some(-60))); + assert!(config.accepts_rssi(Some(-90)), "the boundary is inclusive"); + assert!(!config.accepts_rssi(Some(-91))); + assert!( + config.accepts_rssi(None), + "an unknown RSSI is not a weak one" + ); + } + + #[test] + fn command_line_overrides_beat_the_file() { + let mut config = load( + r#" + [general] + only_known = false + min_rssi = -100 + "#, + ) + .expect("config should parse"); + + config.set_only_known(); + config.set_min_rssi(-70); + + assert!(!config.accepts_address(unknown())); + assert!(!config.accepts_rssi(Some(-80))); + } + + #[test] + fn calibration_offsets_are_added_to_the_reading() { + let settings = SensorSettings { + name: None, + temperature_offset: -0.3, + humidity_offset: 1.5, + }; + let mut data = SensorData { + temperature: Some(22.90), + humidity: Some(64.25), + battery: Some(16), + ..Default::default() + }; + + settings.calibrate(&mut data); + + assert!((data.temperature.unwrap() - 22.60).abs() < 0.0005); + assert!((data.humidity.unwrap() - 65.75).abs() < 0.0005); + assert_eq!( + data.battery, + Some(16), + "only offsets that exist are applied" + ); + } + + #[test] + fn calibration_leaves_absent_measurements_absent() { + let settings = SensorSettings { + name: None, + temperature_offset: -0.3, + humidity_offset: 1.5, + }; + let mut data = SensorData::default(); + + settings.calibrate(&mut data); + + assert_eq!(data.temperature, None); + assert_eq!(data.humidity, None); + } + + #[test] + fn a_malformed_mac_is_rejected() { + for mac in [ + "A4:C1:38:00:11", + "A4:C1:38:00:11:22:33", + "A4C13800 1122", + "A4:C1:38:00:11:2", + "A4:C1:38:00:11:ZZ", + "", + ] { + let toml = format!("[[sensor]]\nmac = \"{mac}\"\n"); + assert!( + matches!(load(&toml), Err(ConfigError::InvalidAddress(_))), + "{mac:?} should have been rejected" + ); + } + } + + #[test] + fn the_same_sensor_twice_is_rejected() { + let result = load( + r#" + [[sensor]] + mac = "A4:C1:38:00:11:22" + name = "First" + + [[sensor]] + mac = "A4:C1:38:00:11:22" + name = "Second" + "#, + ); + + assert!(matches!(result, Err(ConfigError::DuplicateSensor(_)))); + } + + /// A typo in a key should be reported rather than silently ignored. + #[test] + fn an_unknown_key_is_rejected() { + let result = load( + r#" + [[sensor]] + mac = "A4:C1:38:00:11:22" + temp_offset = -0.3 + "#, + ); + + assert!(matches!(result, Err(ConfigError::Parse { .. }))); + } + + #[test] + fn a_missing_file_is_reported_with_its_path() { + let result = Config::load(Path::new("/nonexistent/mitempr.toml")); + + let error = result.expect_err("a missing file is an error"); + assert!( + error.to_string().contains("/nonexistent/mitempr.toml"), + "{error}" + ); + } +} diff --git a/src/decoder.rs b/src/decoder.rs index 23e904c..0d2abad 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -1,17 +1,43 @@ use std::collections::HashMap; +use std::fmt; use uuid::Uuid; -#[derive(Debug)] -pub enum BlePacketType { - Mijia, // 0xFE95 - BTHome, // 0xFCD2 - Pvvx, // 0x181A +/// The advertisement format a sensor is broadcasting in. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum SensorFormat { + /// Xiaomi MiBeacon, service UUID 0xFE95. + MiBeacon, + /// BTHome v2, service UUID 0xFCD2. + BtHome, + /// PVVX custom format, service UUID 0x181A. + Pvvx, + /// Nothing this tool understands. + #[default] Other, } +impl SensorFormat { + pub fn as_str(self) -> &'static str { + match self { + Self::MiBeacon => "mibeacon", + Self::BtHome => "bthome", + Self::Pvvx => "pvvx", + Self::Other => "other", + } + } +} + +impl fmt::Display for SensorFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + // --- SensorData Struct (from your working code) --- #[derive(Debug, Default)] pub struct SensorData { + /// Which decoder produced this reading. + pub format: SensorFormat, pub temperature: Option, pub humidity: Option, pub battery: Option, @@ -44,62 +70,64 @@ const BTHOME_SERVICE_UUID: Uuid = Uuid::from_u128(0x0000FCD2_0000_1000_8000_0080 const PVVX_SERVICE_UUID: Uuid = Uuid::from_u128(0x0000181A_0000_1000_8000_00805F9B34FB); // Function to check the Service Data keys and return the classification -fn get_packet_type(service_data: &HashMap>) -> (BlePacketType, Option<&Vec>) { +fn get_packet_type(service_data: &HashMap>) -> (SensorFormat, Option<&[u8]>) { if let Some(data) = service_data.get(&MIJIA_SERVICE_UUID) { - return (BlePacketType::Mijia, Some(data)); + return (SensorFormat::MiBeacon, Some(data.as_slice())); } if let Some(data) = service_data.get(&BTHOME_SERVICE_UUID) { - return (BlePacketType::BTHome, Some(data)); + return (SensorFormat::BtHome, Some(data.as_slice())); } if let Some(data) = service_data.get(&PVVX_SERVICE_UUID) { - return (BlePacketType::Pvvx, Some(data)); + return (SensorFormat::Pvvx, Some(data.as_slice())); } - (BlePacketType::Other, None) + (SensorFormat::Other, None) } -/// Decode or print service data from BLE advertisements. +/// Decode service data from a BLE advertisement. /// /// This function is intentionally crate-agnostic: it doesn't depend on `bluer` /// or any Bluetooth stack, only on standard Rust types. +/// +/// Decode failures are logged at debug level, not warn: a sensor broadcasts +/// several times a minute, so a payload this tool cannot read would otherwise +/// repeat the same warning forever. pub fn handle_service_data(data: &HashMap>) -> Option { - let (packet_type, payload) = get_packet_type(data); - - match packet_type { - BlePacketType::Mijia => { - if let Some(bytes) = payload { - match decode_mijia(bytes) { - Ok(decoded) => { - //println!(" 🔍 Decoded Mijia data: {:?}", decoded); - return Some(decoded); - } - Err(e) => { - println!(" ⚠️ Could not decode Mijia payload: {}", e); - } - } - } - } + let (format, payload) = get_packet_type(data); + let payload = payload?; - BlePacketType::BTHome => { - if let Some(bytes) = payload { - if let Some(decoded) = decode_bthome(bytes) { - //println!(" 🔍 Decoded BTHome data: {:?}", decoded); - return Some(decoded); - } else { - println!(" ⚠️ Could not decode BTHome payload"); - } - } - } + match format { + SensorFormat::MiBeacon => match decode_mijia(payload) { + Ok(decoded) => return Some(decoded), + Err(e) => log::debug!("could not decode MiBeacon payload: {e}"), + }, - BlePacketType::Pvvx => { - if let Some(bytes) = payload { - if let Some(decoded) = decode_pvvx(bytes) { - //println!(" 🔍 Decoded PVVX data: {:?}", decoded); - return Some(decoded); - } else { - println!(" ⚠️ Could not decode PVVX payload"); - } - } - } + SensorFormat::BtHome => match decode_bthome(payload) { + Some(decoded) => return Some(decoded), + None => log::debug!("could not decode BTHome payload"), + }, + + SensorFormat::Pvvx => match decode_pvvx(payload) { + Some(decoded) => return Some(decoded), + None => log::debug!("could not decode PVVX payload"), + }, + + // Every phone, watch and TV in range lands here. + SensorFormat::Other => {} + } + + None +} + +// --- BTHome Decoder --- + +/// The BTHome v2 device-information byte, i.e. the first byte of the service +/// data. See . +struct BthomeDeviceInfo { + /// Bits 5-7. This decoder only understands version 2. + version: u8, + /// Bit 0. When set, everything after this byte is ciphertext. + encrypted: bool, +} BlePacketType::Other => { // Every phone, watch and TV in range lands here. Now that each @@ -107,8 +135,45 @@ pub fn handle_service_data(data: &HashMap>) -> Option // device, saying so on every packet drowns out the readings. } } +} - None +/// Number of value bytes that follow a BTHome object id, or `None` if the +/// length cannot be determined. +/// +/// This is what keeps the parser in sync: an object we do not interpret still +/// has to be skipped by exactly the right number of bytes, otherwise the next +/// value byte gets read as an object id. Lengths are from +/// . `tail` is the payload after the object id and +/// is only needed for the few variable-length objects. +fn bthome_value_len(object_id: u8, tail: &[u8]) -> Option { + let len = match object_id { + // 1-byte sensor values: packet id, battery, count, humidity, moisture, + // UV index, temperature, channel, light level, settings revision. + 0x00 | 0x01 | 0x09 | 0x2E | 0x2F | 0x46 | 0x57 | 0x58 | 0x59 | 0x60 | 0x64 | 0x65 => 1, + // 2-byte sensor values. + 0x02 | 0x03 | 0x06 | 0x07 | 0x08 | 0x0C | 0x0D | 0x0E | 0x12 | 0x13 | 0x14 | 0x40 + | 0x41 | 0x43 | 0x44 | 0x45 | 0x47 | 0x48 | 0x49 | 0x4A | 0x51 | 0x52 | 0x56 | 0x5A + | 0x5D | 0x5E | 0x5F | 0x61 => 2, + // 3-byte sensor values: pressure, illuminance, energy, power, duration, gas. + 0x04 | 0x05 | 0x0A | 0x0B | 0x42 | 0x4B => 3, + // 4-byte sensor values. + 0x4C | 0x4D | 0x4E | 0x4F | 0x50 | 0x55 | 0x5B | 0x5C | 0x62 | 0x63 => 4, + // Binary sensors and the button event are one byte each. + 0x0F..=0x11 | 0x15..=0x2D | 0x3A => 1, + // Command and dimmer events carry a step count for some event types. + 0x3B => match *tail.first()? { + 0x03 | 0x04 => 2, + _ => 1, + }, + 0x3C => match *tail.first()? { + 0x01 | 0x02 => 2, + _ => 1, + }, + // Text and raw are length-prefixed. + 0x53 | 0x54 => 1 + usize::from(*tail.first()?), + _ => return None, + }; + Some(len) } // --- BTHome Decoder --- @@ -250,7 +315,7 @@ fn decode_bthome(payload: &[u8]) -> Option { } // --- PVVX Decoder --- -fn decode_pvvx(payload: &Vec) -> Option { +fn decode_pvvx(payload: &[u8]) -> Option { const MIN_LENGTH: usize = 15; const MAC_LENGTH: usize = 6; @@ -294,6 +359,7 @@ fn decode_pvvx(payload: &Vec) -> Option { }; Some(SensorData { + format: SensorFormat::Pvvx, temperature, humidity, battery, diff --git a/src/main.rs b/src/main.rs index 5952504..cdd9d4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,34 +8,100 @@ use tokio::sync::{Mutex, mpsc}; use tokio::time::sleep; use uuid::Uuid; mod decoder; +mod output; +mod scan; -/// Simple BLE discovery tool with watchdog restart (Python-style) +use config::Config; +use output::Format; + +/// Read environmental data from Bluetooth sensors. #[derive(Parser, Debug)] #[command(author, version, about)] struct Args { - /// Watchdog timeout in seconds (restart if no packets seen) + /// Configuration file naming and calibrating known sensors + #[arg(long, value_name = "PATH")] + config: Option, + + /// Ignore sensors that have no entry in the configuration file + #[arg(long)] + only_known: bool, + + /// Ignore advertisements weaker than this, in dBm (e.g. -90) + #[arg(long, value_name = "DBM", allow_negative_numbers = true)] + min_rssi: Option, + + /// Restart discovery if no reading arrives for this many seconds #[arg(long, default_value_t = 20)] watchdog: u64, - /// Cooldown pause between restarts in seconds + /// Cooldown pause between discovery restarts in seconds #[arg(long, default_value_t = 5)] cooldown: u64, + + /// How to write readings: a human-readable line, or one JSON object per line + #[arg(long, value_enum, default_value_t = Format::Text)] + format: Format, + + /// Log more; repeat for even more (-v debug, -vv trace) + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, + + /// Log only warnings and errors. Readings written with --format json are not + /// affected, since those go to stdout rather than through the logger. + #[arg(short, long, conflicts_with = "verbose")] + quiet: bool, +} + +impl Args { + fn log_level(&self) -> LevelFilter { + if self.quiet { + return LevelFilter::Warn; + } + match self.verbose { + 0 => LevelFilter::Info, + 1 => LevelFilter::Debug, + _ => LevelFilter::Trace, + } + } + + /// Read the configuration file, if one was given, and let the command line + /// override what it says. + fn load_config(&self) -> Result { + let mut config = match &self.config { + Some(path) => Config::load(path)?, + None => Config::default(), + }; + + if self.only_known { + config.set_only_known(); + } + if let Some(min_rssi) = self.min_rssi { + config.set_min_rssi(min_rssi); + } + + Ok(config) + } } /// The service data of the advertisement last seen from a device. type ServiceData = HashMap>; #[tokio::main(flavor = "multi_thread", worker_threads = 2)] -async fn main() -> Result<()> { +async fn main() -> Result<(), Box> { let args = Args::parse(); + // RUST_LOG still wins, so a systemd unit can turn on debug logging without + // its command line changing. + env_logger::Builder::new() + .filter_level(args.log_level()) + .parse_env("RUST_LOG") + .init(); + + let config = args.load_config()?; + let session = bluer::Session::new().await?; let adapter = session.default_adapter().await?; adapter.set_powered(true).await?; - println!( - "Starting robust continuous BLE discovery (watchdog={}s, cooldown={}s)...", - args.watchdog, args.cooldown - ); // The advertisement each device last sent, so that a property change which // carries no new reading (an RSSI update, say) is not reported twice. @@ -110,10 +176,9 @@ async fn main() -> Result<()> { } } - // Small delay before reinitializing discovery - sleep(Duration::from_secs(2)).await; - } - }); + tokio::select! { + result = scan::run(&adapter, settings) => result?, + () = shutdown_requested() => log::info!("shutting down"), } // @@ -131,11 +196,11 @@ async fn main() -> Result<()> { AdapterEvent::DeviceRemoved(addr) => { last_service_data.remove(&addr); } - _ => {} } } - Ok(()) + #[cfg(not(unix))] + wait_for_ctrl_c().await; } async fn handle_device( diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..4b7b7e3 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,203 @@ +//! Turning a decoded advertisement into something outside this process can use. + +use crate::decoder::SensorData; +use bluer::Address; +use serde::Serialize; +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// How readings are written. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)] +pub enum Format { + /// One human-readable line per reading, through the logger, so it picks up + /// the same timestamp and level filtering as everything else. + #[default] + Text, + /// One JSON object per line, written straight to stdout so it can be piped + /// into `jq` and friends without log decoration getting in the way. + Json, +} + +/// A decoded reading plus where and when it came from. +/// +/// Field names carry their unit so that a consumer of the JSON does not have to +/// guess. Absent measurements are omitted rather than serialised as `null`. +#[derive(Debug, Serialize)] +pub struct Reading { + /// Seconds since the Unix epoch. + pub timestamp: u64, + pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub format: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub rssi_dbm: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature_celsius: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub humidity_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub battery_percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub voltage_volts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pressure_hpa: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub illuminance_lux: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub moisture_percent: Option, +} + +impl Reading { + pub fn new( + address: Address, + name: Option, + rssi: Option, + data: &SensorData, + ) -> Self { + Self { + timestamp: unix_timestamp(), + address: address.to_string(), + name, + format: data.format.as_str(), + rssi_dbm: rssi, + temperature_celsius: data.temperature, + humidity_percent: data.humidity, + battery_percent: data.battery, + voltage_volts: data.voltage, + pressure_hpa: data.pressure, + illuminance_lux: data.illuminance, + moisture_percent: data.moisture, + } + } + + /// Write this reading in the requested format. + pub fn emit(&self, format: Format) { + match format { + Format::Text => log::info!("{self}"), + Format::Json => match serde_json::to_string(self) { + Ok(line) => println!("{line}"), + Err(e) => log::error!("could not serialise reading: {e}"), + }, + } + } +} + +/// Seconds since the Unix epoch, or 0 if the clock is set before 1970. +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +impl fmt::Display for Reading { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} [{}]", self.address, self.format)?; + + if let Some(name) = &self.name { + write!(f, " {name}")?; + } + if let Some(rssi) = self.rssi_dbm { + write!(f, " {rssi} dBm")?; + } + + write!(f, " |")?; + if let Some(value) = self.temperature_celsius { + write!(f, " {value:.2} C")?; + } + if let Some(value) = self.humidity_percent { + write!(f, " {value:.2} %RH")?; + } + if let Some(value) = self.pressure_hpa { + write!(f, " {value:.2} hPa")?; + } + if let Some(value) = self.illuminance_lux { + write!(f, " {value:.0} lx")?; + } + if let Some(value) = self.moisture_percent { + write!(f, " {value:.0} % moisture")?; + } + if let Some(value) = self.voltage_volts { + write!(f, " {value:.3} V")?; + } + if let Some(value) = self.battery_percent { + write!(f, " {value} % battery")?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decoder::SensorFormat; + + fn sample() -> SensorData { + SensorData { + format: SensorFormat::Pvvx, + temperature: Some(22.90), + humidity: Some(64.25), + battery: Some(16), + voltage: Some(2.333), + ..Default::default() + } + } + + fn reading() -> Reading { + let mut reading = Reading::new( + Address::new([0xA4, 0xC1, 0x38, 0xA0, 0x7B, 0x03]), + Some("LYWSD03MMC".to_string()), + Some(-67), + &sample(), + ); + // Pin the clock so the expectations below are stable. + reading.timestamp = 1_770_000_000; + reading + } + + #[test] + fn text_output_lists_every_present_measurement() { + assert_eq!( + reading().to_string(), + "A4:C1:38:A0:7B:03 [pvvx] LYWSD03MMC -67 dBm | 22.90 C 64.25 %RH 2.333 V 16 % battery" + ); + } + + #[test] + fn json_output_is_one_object_with_units_in_the_keys() { + let json = serde_json::to_string(&reading()).expect("reading should serialise"); + + assert_eq!( + json, + concat!( + r#"{"timestamp":1770000000,"address":"A4:C1:38:A0:7B:03","#, + r#""name":"LYWSD03MMC","format":"pvvx","rssi_dbm":-67,"#, + r#""temperature_celsius":22.9,"humidity_percent":64.25,"#, + r#""battery_percent":16,"voltage_volts":2.333}"# + ) + ); + } + + /// Measurements a sensor does not report must be left out entirely rather + /// than serialised as null, so consumers can tell "not measured" from + /// "measured as zero". + #[test] + fn json_output_omits_absent_measurements() { + let data = SensorData { + format: SensorFormat::BtHome, + temperature: Some(24.29), + ..Default::default() + }; + let mut reading = Reading::new(Address::new([0; 6]), None, None, &data); + reading.timestamp = 0; + + let json = serde_json::to_string(&reading).expect("reading should serialise"); + + assert!(json.contains(r#""temperature_celsius":24.29"#)); + assert!(!json.contains("humidity"), "{json}"); + assert!(!json.contains("name"), "{json}"); + assert!(!json.contains("rssi"), "{json}"); + assert!(!json.contains("null"), "{json}"); + } +} diff --git a/src/scan.rs b/src/scan.rs new file mode 100644 index 0000000..4558d96 --- /dev/null +++ b/src/scan.rs @@ -0,0 +1,217 @@ +//! Continuous BLE discovery with a watchdog that restarts a wedged scan. + +use crate::config::Config; +use crate::decoder; +use crate::output::{Format, Reading}; +use bluer::{Adapter, AdapterEvent, Address, DeviceProperty, Result}; +use futures::StreamExt; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, mpsc}; +use tokio::time::sleep; +use uuid::Uuid; + +/// How often the watchdog checks whether readings are still arriving. +const WATCHDOG_TICK: Duration = Duration::from_secs(5); +/// Settle time before discovery is started again. +const RESTART_DELAY: Duration = Duration::from_secs(2); + +/// The service data of the advertisement last seen from a device. +type ServiceData = HashMap>; + +pub struct Settings { + /// Restart discovery when no reading arrives for this long. + pub watchdog: Duration, + /// Pause between restarts. + pub cooldown: Duration, + /// How to write readings. + pub format: Format, + /// Which sensors to report, and how to name and calibrate them. + pub config: Config, +} + +/// Scan until the adapter disappears or the task is cancelled. +pub async fn run(adapter: &Adapter, settings: Settings) -> Result<()> { + // The advertisement each device last sent, so that a property change which + // carries no new reading (an RSSI update, say) is not reported twice. + let mut last_service_data: HashMap = HashMap::new(); + let last_reading_at = Arc::new(Mutex::new(Instant::now())); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + spawn_discovery( + adapter.clone(), + tx, + last_reading_at.clone(), + settings.watchdog, + settings.cooldown, + ); + + while let Some(event) = rx.recv().await { + match event { + AdapterEvent::DeviceAdded(addr) => { + // Checked before reading any properties, so a filtered-out device + // costs nothing but the event itself. + if !settings.config.accepts_address(addr) { + continue; + } + + if let Err(e) = handle_device( + adapter, + addr, + &mut last_service_data, + &last_reading_at, + &settings, + ) + .await + { + log::warn!("error handling device {addr}: {e}"); + } + } + AdapterEvent::DeviceRemoved(addr) => { + log::debug!("device removed: {addr}"); + last_service_data.remove(&addr); + } + _ => {} + } + } + + Ok(()) +} + +/// Run discovery in the background, forwarding events and restarting the scan if +/// it goes quiet. +fn spawn_discovery( + adapter: Adapter, + tx: mpsc::UnboundedSender, + last_reading_at: Arc>, + watchdog: Duration, + cooldown: Duration, +) { + tokio::spawn(async move { + let mut restarts: u64 = 0; + + loop { + log::info!("starting discovery"); + + // discover_devices_with_changes() re-emits DeviceAdded every time a + // device's properties change, which is what makes this a continuous + // reader rather than a one-shot inventory. + let mut events = match adapter.discover_devices_with_changes().await { + Ok(events) => events, + Err(e) => { + log::error!("failed to start discovery: {e}"); + sleep(cooldown).await; + continue; + } + }; + + loop { + tokio::select! { + event = events.next() => { + match event { + Some(event) => { + if tx.send(event).is_err() { + // The reader is gone, so there is nobody left + // to scan for. + return; + } + } + None => { + log::warn!("discovery stream ended, restarting"); + break; + } + } + } + + _ = sleep(WATCHDOG_TICK) => { + let idle = last_reading_at.lock().await.elapsed(); + if idle > watchdog { + restarts += 1; + log::warn!( + "watchdog: no readings for {idle:?}, restarting discovery \ + (restart {restarts})" + ); + + // Dropping the stream is the equivalent of + // disable_le_scan. + drop(events); + sleep(cooldown).await; + break; + } + } + } + } + + sleep(RESTART_DELAY).await; + } + }); +} + +async fn handle_device( + adapter: &Adapter, + addr: Address, + last_service_data: &mut HashMap, + last_reading_at: &Mutex, + settings: &Settings, +) -> Result<()> { + let device = adapter.device(addr)?; + + // One D-Bus round-trip for every property, instead of one round-trip each for + // the name, the RSSI and the service data. At one advertisement per second + // per sensor that is the difference the Pi Zero W notices. + let mut name: Option = None; + let mut rssi = None; + let mut service_data = None; + for property in device.all_properties().await? { + match property { + DeviceProperty::Name(value) => name = Some(value), + DeviceProperty::Rssi(value) => rssi = Some(value), + DeviceProperty::ServiceData(value) => service_data = Some(value), + _ => {} + } + } + + // No service data at all: not something this tool can read. + let Some(service_data) = service_data else { + return Ok(()); + }; + + if !settings.config.accepts_rssi(rssi) { + log::trace!("{addr}: ignored, RSSI {rssi:?} is below the configured minimum"); + return Ok(()); + } + + // Identical to the advertisement we already reported, so some other property + // changed. Sensors bump a packet counter on every broadcast, so a genuinely + // new reading always differs here. + if last_service_data.get(&addr) == Some(&service_data) { + return Ok(()); + } + + if log::log_enabled!(log::Level::Trace) { + for (uuid, bytes) in &service_data { + log::trace!("{addr} service {uuid}: {bytes:02X?}"); + } + } + + if let Some(mut data) = decoder::handle_service_data(&service_data) { + // A configured name beats whatever the device calls itself, and the + // calibration offsets are applied before anything sees the reading. + if let Some(sensor) = settings.config.settings(addr) { + sensor.calibrate(&mut data); + if sensor.name.is_some() { + name = sensor.name.clone(); + } + } + + Reading::new(addr, name, rssi, &data).emit(settings.format); + + // Only an actual reading counts as proof that the scan is alive. + *last_reading_at.lock().await = Instant::now(); + } + + last_service_data.insert(addr, service_data); + + Ok(()) +}