From 8fe75daad623c2598a10934016269845063f580b Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 8 Oct 2026 21:23:39 +0200 Subject: [PATCH] presence: BLE proximity events for a kiosk screen A swayidle-shaped daemon: `present ` runs when a Bluetooth LE device comes within an RSSI threshold, `absent ` once nobody has for that long. Near means 3 of the last 5 advertisements at or above -58 dBm by default; --calibrate prints the raw stream so the threshold is picked at the installed screen, not guessed. Nothing is stored: a few seconds of samples in memory, counted. Packaged for the [uhhm] registry from the checkout itself, built natively per arch on tag by Gitea Actions, with the version checked across Cargo.toml, PKGBUILD and the tag. Co-Authored-By: Claude Fable 5.1 --- .gitea/workflows/ci.yml | 23 + .gitea/workflows/release.yml | 61 +++ .gitignore | 5 + Cargo.lock | 850 +++++++++++++++++++++++++++++++++++ Cargo.toml | 12 + LICENSE | 21 + PKGBUILD | 31 ++ README.md | 99 ++++ scripts/check-version | 13 + scripts/publish-registry | 19 + src/main.rs | 297 ++++++++++++ 11 files changed, 1431 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 PKGBUILD create mode 100644 README.md create mode 100755 scripts/check-version create mode 100755 scripts/publish-registry create mode 100644 src/main.rs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..eed3aa3 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + container: archlinux:latest + steps: + - name: Prepare container + run: pacman -Syu --noconfirm --needed base-devel git rust dbus + - name: Checkout + uses: builtin:checkout + - name: Format, lint, build + run: | + cargo fmt --check + cargo clippy --locked --release -- -D warnings + cargo build --locked --release + - name: PKGBUILD and Cargo.toml agree + run: scripts/check-version diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4e9d948 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release + +# A v* tag builds the Arch package natively per arch and uploads it to the +# [uhhm] registry (namespace bl, as pacman.conf points there). REGISTRY_TOKEN +# is a bl write:package token added as a repo secret; org repos do not inherit +# user-level secrets. +on: + push: + tags: + - 'v*' + +env: + PACKAGER: uhhm CI + # Arch Linux ARM's makepkg.conf (klokka) differs from Arch's: pin where + # packages land and their format, so publish-registry finds them. + PKGDEST: . + PKGEXT: .pkg.tar.zst + +jobs: + # runner-1 (Docker on ergo): Arch container on the ubuntu-latest label. + x86_64: + runs-on: ubuntu-latest + container: archlinux:latest + steps: + - name: Prepare container + run: pacman -Syu --noconfirm --needed base-devel git rust dbus + - name: Checkout + uses: builtin:checkout + - name: Check tag, PKGBUILD and Cargo.toml agree + run: scripts/check-version "${GITHUB_REF_NAME#v}" + - name: Build + run: | + useradd -m builder + chown -R builder: . + sudo -u builder --preserve-env=PACKAGER,PKGDEST,PKGEXT makepkg -f --noconfirm + - name: Publish to the Arch package registry + run: scripts/publish-registry . + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_URL: ${{ gitea.server_url }}/api/packages/bl/arch/uhhm + + # klokka: bare Arch Linux ARM host. Nothing is installed with pacman there; + # cargo comes from the rustup toolchain the action keeps in the runner's + # home, hence -d (makepkg would otherwise look for a cargo *package*). + # libdbus is on the host already (bluez pulls it in). + aarch64: + runs-on: aarch64 + steps: + - name: Checkout + uses: builtin:checkout + - name: Rust stable + uses: dtolnay/rust-toolchain@stable + - name: Check tag, PKGBUILD and Cargo.toml agree + run: scripts/check-version "${GITHUB_REF_NAME#v}" + - name: Build + run: makepkg -f -d --noconfirm + - name: Publish to the Arch package registry + run: scripts/publish-registry . + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_URL: ${{ gitea.server_url }}/api/packages/bl/arch/uhhm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3d9b71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +*.pkg.tar.zst +/pkg +/src/*/ +!/src/*.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..eb63c25 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,850 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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 = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[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 = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +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 = "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 = "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 = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "presence" +version = "0.1.0" +dependencies = [ + "bluer", + "clap", + "futures", + "tokio", +] + +[[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 = "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.6", +] + +[[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 = "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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +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 = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +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.6", +] + +[[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 = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +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.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +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 = "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..3a1474b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "presence" +version = "0.1.0" +description = "BLE proximity events for a kiosk screen, swayidle style" +license = "MIT" +edition = "2024" + +[dependencies] +bluer = { version = "0.17", features = ["bluetoothd"] } +clap = { version = "4.6.7", features = ["derive"] } +futures = "0.3.34" +tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "signal", "process", "time", "sync"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..add8a78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Bendik Aagaard Lynghaug + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..a30add6 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,31 @@ +# Maintainer: bl +# Built from the checkout it sits in: .gitea/workflows/release.yml runs makepkg +# at the repo root on a v* tag, natively per arch, and uploads to the [uhhm] +# registry. pkgver must match Cargo.toml and the tag; CI checks all three. +# +# source=() and $startdir on purpose: makepkg's own $srcdir is ./src, which is +# where the Rust sources live, so copying into it would nest src/src. Cargo +# builds in place into ./target (gitignored) instead. + +pkgname=presence +pkgver=0.1.0 +pkgrel=1 +pkgdesc="BLE proximity events for a kiosk screen, swayidle style" +arch=('x86_64' 'aarch64') +url="https://project.uhhm.no/uhhm/presence" +license=('MIT') +depends=('bluez' 'dbus' 'gcc-libs' 'glibc') +makedepends=('cargo') +source=() + +build() { + cd "$startdir" + CARGO_TARGET_DIR="$startdir/target" cargo build --release --locked +} + +package() { + cd "$startdir" + install -Dm755 target/release/presence "$pkgdir/usr/bin/presence" + install -Dm644 README.md "$pkgdir/usr/share/doc/presence/README.md" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/presence/LICENSE" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d85a99 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# presence + +BLE proximity events for a kiosk screen, in the spirit of swayidle: you give +it commands to run when someone comes near and when nobody has been near for a +while. It listens to Bluetooth LE advertisements through BlueZ and treats their +signal strength as a rough distance. + +``` +exec presence --rssi -58 \ + present 'swaymsg "output * power on"' \ + absent 600 'swaymsg "output * power off"' +``` + +## How it decides + +Every advertisement BlueZ hears is one RSSI sample for that device. A device +is **near** when at least `--hits` of its last `--window` samples are at or +above `--rssi` (defaults: 3 of 5 at -58 dBm). Samples older than `--stale` +seconds are forgotten, so a phone that stops advertising stops counting. + +Someone is **present** while any device is near. `present` commands run when +that becomes true. Each `absent ` runs once, after nobody +has been near for that many seconds. `present` fires again only after at +least one `absent` has fired, so a phone flickering around the threshold does +not run it over and over. With no `absent` events, `present` fires on every +arrival. + +## What it cannot do + +RSSI is a poor ruler. The same phone reads 15 dB apart in a hand and in a back +pocket, bodies in between swing it by 10 dB, and phone models differ in +transmit power. A phone held out at 4 m and one pocketed at 1.5 m can read the +same. Not every phone advertises: iPhones do constantly, many Androids only +while their screen is on. Departure is only visible as silence, hence the +timeout. + +Treat it as "someone with a phone is around", with a threshold you tune on +site. For a real 2 m boundary with millisecond response use a 24 GHz mmWave +module (HLK-LD2410) instead; this tool exists to try the cheap thing first. + +Addresses are randomized by modern phones and are never written anywhere by +`presence`. It keeps a few seconds of samples in memory and counts. If you +log `--calibrate` output, you are the one storing identifiers; do not keep it. + +## Calibrating + +Run it at the installed screen, with the display mounted where it will hang, +because the wall and the panel change the readings: + +``` +presence --calibrate +``` + +It prints every advertisement as `seconds address rssi NEAR name` and runs +nothing. Stand where a visitor stands with your phone in a pocket, then at 4 m, +then walk past. Pick `--rssi` from what you see; the default -58 is a starting +point, not a measurement. `--ignore ` drops the staff phones and the +kiosk's own peripherals once you know their addresses. + +## Options + +``` +--rssi sample counts as near at or above this (default -58) +--window samples kept per device (default 5) +--hits near samples out of the window that make a device near (default 3) +--stale forget samples older than this (default 15) +--adapter which adapter (default: the first) +--ignore repeatable; addresses to skip +--calibrate print advertisements, run nothing +-w, --wait wait for each command to finish, like swayidle -w +``` + +Commands run through `sh -c` as the user who started `presence`, which in a +sway config is the session user, so `swaymsg` finds the right socket. + +## Requirements + +`bluetoothd` must be running (`systemctl enable --now bluetooth`). BlueZ's +default D-Bus policy lets any user start discovery, so no root is needed. On a +Raspberry Pi 3 the BLE controller shares the antenna with wifi; scanning costs +wifi throughput, which is one more reason to wire the kiosk. + +## Install + +From the `[uhhm]` Arch registry on x86_64 and aarch64: + +``` +sudo pacman -S presence +``` + +Or build it: `cargo build --release`, or `makepkg` at the repo root. + +## Release + +Bump `version` in `Cargo.toml` and `pkgver` in `PKGBUILD` to the same string, +commit, tag `vX.Y.Z`. CI refuses a tag that disagrees with either. Gitea +Actions then builds the package natively on both arches and uploads it to the +registry. The `REGISTRY_TOKEN` repo secret is a bl `write:package` token; org +repos do not inherit it. diff --git a/scripts/check-version b/scripts/check-version new file mode 100755 index 0000000..3c44167 --- /dev/null +++ b/scripts/check-version @@ -0,0 +1,13 @@ +#!/bin/bash +# check-version [tag-version]: PKGBUILD pkgver, Cargo.toml version and, when +# given, the tag must all be the same string. Version lives in Cargo.toml; +# PKGBUILD mirrors it so makepkg needs no VCS pkgver(). +set -euo pipefail +cd "$(dirname "$0")/.." +cargo=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) +pkg=$(sed -n 's/^pkgver=//p' PKGBUILD) +[ "$cargo" = "$pkg" ] || { echo "Cargo.toml $cargo != PKGBUILD pkgver $pkg" >&2; exit 1; } +if [ $# -gt 0 ]; then + [ "$1" = "$cargo" ] || { echo "tag $1 != version $cargo" >&2; exit 1; } +fi +echo "version $cargo" diff --git a/scripts/publish-registry b/scripts/publish-registry new file mode 100755 index 0000000..5ffc164 --- /dev/null +++ b/scripts/publish-registry @@ -0,0 +1,19 @@ +#!/bin/bash +# publish-registry : upload every built package in (debug packages +# skipped) to the [uhhm] Gitea Arch registry. HTTP 409 means that +# pkgver-pkgrel is already published, which is fine on re-runs. +set -euo pipefail +: "${REGISTRY_TOKEN:?}" "${REGISTRY_URL:?}" +shopt -s nullglob +pkgs=("$1"/*.pkg.tar.{zst,xz,gz}) +[ ${#pkgs[@]} -gt 0 ] || { echo "no packages in $1" >&2; exit 1; } +for pkg in "${pkgs[@]}"; do + case "$pkg" in *-debug-*) continue ;; esac + code=$(curl -sS -o /tmp/registry-resp -w '%{http_code}' \ + --user "bl:$REGISTRY_TOKEN" --upload-file "$pkg" "$REGISTRY_URL") + case "$code" in + 2??) echo "uploaded $(basename "$pkg")" ;; + 409) echo "already published: $(basename "$pkg")" ;; + *) echo "::error::$(basename "$pkg"): HTTP $code"; cat /tmp/registry-resp; exit 1 ;; + esac +done diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..16e4edf --- /dev/null +++ b/src/main.rs @@ -0,0 +1,297 @@ +//! presence: BLE proximity events for a kiosk, in the spirit of swayidle. +//! +//! Every Bluetooth LE advertisement BlueZ hears is one RSSI sample for that +//! device. A device is "near" when at least `--hits` of its last `--window` +//! samples are at or above `--rssi`. Someone is present when any device is +//! near. `present` commands run when that becomes true; `absent ` +//! commands run once nobody has been near for that many seconds. +//! +//! RSSI is a poor ruler: the same phone reads 15 dB apart in a hand and in a +//! back pocket. Run with `--calibrate` at the installed screen before trusting +//! any threshold. + +use bluer::{Adapter, AdapterEvent, Address, DiscoveryFilter, DiscoveryTransport, Session}; +use clap::Parser; +use futures::StreamExt; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::process::ExitCode; +use std::time::{Duration, Instant}; +use tokio::process::Command; + +#[derive(Parser, Debug)] +#[command( + version, + about = "BLE proximity events for a kiosk screen", + after_help = "EVENTS (repeatable, in any order):\n \ + present run when a device comes within range\n \ + absent run once nobody has been in range for \n\n\ + Example (sway config):\n \ + exec presence --rssi -58 \\\n \ + present 'swaymsg \"output * power on\"' \\\n \ + absent 600 'swaymsg \"output * power off\"'" +)] +struct Args { + /// RSSI at or above which a sample counts as near, in dBm + #[arg(long, default_value_t = -58, allow_hyphen_values = true)] + rssi: i16, + /// Samples kept per device + #[arg(long, default_value_t = 5)] + window: usize, + /// Samples at or above --rssi (out of --window) that make a device near + #[arg(long, default_value_t = 3)] + hits: usize, + /// Forget samples older than this many seconds + #[arg(long, default_value_t = 15)] + stale: u64, + /// Bluetooth adapter to use (default: the first one) + #[arg(long)] + adapter: Option, + /// Addresses to ignore, e.g. staff phones or the kiosk's own peripherals + #[arg(long = "ignore", value_name = "ADDR")] + ignore: Vec
, + /// Print every advertisement (time, address, rssi, near?, name) and run nothing + #[arg(long)] + calibrate: bool, + /// Wait for each command to finish before handling further events + #[arg(short = 'w', long)] + wait: bool, + /// Event definitions, swayidle style (see EVENTS below) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + events: Vec, +} + +struct Absent { + after: Duration, + command: String, + fired: bool, +} + +struct Events { + present: Vec, + absent: Vec, +} + +fn parse_events(tokens: &[String]) -> Result { + let mut ev = Events { + present: Vec::new(), + absent: Vec::new(), + }; + let mut i = 0; + while i < tokens.len() { + match tokens[i].as_str() { + "present" => { + let cmd = tokens.get(i + 1).ok_or("present: missing command")?; + ev.present.push(cmd.clone()); + i += 2; + } + "absent" => { + let secs = tokens.get(i + 1).ok_or("absent: missing seconds")?; + let secs: u64 = secs + .parse() + .map_err(|_| format!("absent: bad seconds {secs:?}"))?; + let cmd = tokens.get(i + 2).ok_or("absent: missing command")?; + ev.absent.push(Absent { + after: Duration::from_secs(secs), + command: cmd.clone(), + fired: true, + }); + i += 3; + } + other => { + return Err(format!( + "unknown event {other:?} (expected present or absent)" + )); + } + } + } + Ok(ev) +} + +fn run(command: &str, wait: bool) { + let mut child = Command::new("sh"); + child.arg("-c").arg(command); + let command = command.to_string(); + let fut = async move { + match child.status().await { + Ok(s) if s.success() => {} + Ok(s) => eprintln!("presence: command exited with {s}: {command}"), + Err(e) => eprintln!("presence: failed to run {command:?}: {e}"), + } + }; + if wait { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut)); + } else { + tokio::spawn(fut); + } +} + +struct Samples { + per_device: HashMap>, + window: usize, + hits: usize, + threshold: i16, + stale: Duration, +} + +impl Samples { + fn push(&mut self, addr: Address, rssi: i16, now: Instant) -> bool { + let q = self.per_device.entry(addr).or_default(); + q.push_back((now, rssi)); + while q.len() > self.window { + q.pop_front(); + } + self.is_near(addr, now) + } + + fn is_near(&self, addr: Address, now: Instant) -> bool { + self.per_device + .get(&addr) + .map(|q| { + q.iter() + .filter(|(t, r)| now.duration_since(*t) <= self.stale && *r >= self.threshold) + .count() + >= self.hits + }) + .unwrap_or(false) + } + + fn purge(&mut self, now: Instant) { + let stale = self.stale; + self.per_device.retain(|_, q| { + while q + .front() + .is_some_and(|(t, _)| now.duration_since(*t) > stale) + { + q.pop_front(); + } + !q.is_empty() + }); + } + + fn anyone_near(&self, now: Instant) -> bool { + self.per_device.keys().any(|a| self.is_near(*a, now)) + } +} + +async fn open_adapter(name: Option<&str>) -> bluer::Result { + let session = Session::new().await?; + let adapter = match name { + Some(n) => session.adapter(n)?, + None => session.default_adapter().await?, + }; + adapter.set_powered(true).await?; + adapter + .set_discovery_filter(DiscoveryFilter { + transport: DiscoveryTransport::Le, + duplicate_data: true, + ..Default::default() + }) + .await?; + Ok(adapter) +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() -> ExitCode { + let args = Args::parse(); + let mut events = match parse_events(&args.events) { + Ok(e) => e, + Err(e) => { + eprintln!("presence: {e}"); + return ExitCode::from(2); + } + }; + if !args.calibrate && events.present.is_empty() && events.absent.is_empty() { + eprintln!("presence: no events given and not --calibrate; nothing to do"); + return ExitCode::from(2); + } + if args.hits == 0 || args.hits > args.window { + eprintln!("presence: --hits must be between 1 and --window"); + return ExitCode::from(2); + } + let ignore: HashSet
= args.ignore.iter().copied().collect(); + + let adapter = match open_adapter(args.adapter.as_deref()).await { + Ok(a) => a, + Err(e) => { + eprintln!("presence: bluetooth: {e} (is bluetoothd running?)"); + return ExitCode::from(1); + } + }; + let mut stream = match adapter.discover_devices_with_changes().await { + Ok(s) => s, + Err(e) => { + eprintln!("presence: discovery: {e}"); + return ExitCode::from(1); + } + }; + eprintln!( + "presence: scanning on {} (near = {} of {} samples >= {} dBm, samples expire after {}s)", + adapter.name(), + args.hits, + args.window, + args.rssi, + args.stale + ); + + let mut samples = Samples { + per_device: HashMap::new(), + window: args.window, + hits: args.hits, + threshold: args.rssi, + stale: Duration::from_secs(args.stale), + }; + let started = Instant::now(); + let mut present = false; + let mut last_near = Instant::now(); + let mut tick = tokio::time::interval(Duration::from_secs(1)); + + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + ev = stream.next() => { + let Some(AdapterEvent::DeviceAdded(addr)) = ev else { if ev.is_none() { eprintln!("presence: discovery stream ended"); break; } continue; }; + if ignore.contains(&addr) { continue; } + let Ok(device) = adapter.device(addr) else { continue }; + let Ok(Some(rssi)) = device.rssi().await else { continue }; + let now = Instant::now(); + let near = samples.push(addr, rssi, now); + if args.calibrate { + let name = device.name().await.ok().flatten().unwrap_or_default(); + println!("{:9.1} {} {:4} {} {}", started.elapsed().as_secs_f64(), addr, rssi, if near { "NEAR" } else { " " }, name); + } + } + _ = tick.tick() => { + let now = Instant::now(); + samples.purge(now); + let near = samples.anyone_near(now); + if near { + last_near = now; + // Fire `present` only after at least one absence has been announced, + // so a phone flickering around the threshold does not re-run it. + if !present && (events.absent.is_empty() || events.absent.iter().any(|a| a.fired)) { + present = true; + if !args.calibrate { + for cmd in &events.present { run(cmd, args.wait); } + } + for a in events.absent.iter_mut() { a.fired = false; } + eprintln!("presence: present"); + } else if !present { + present = true; + for a in events.absent.iter_mut() { a.fired = false; } + } + } else { + let away = now.duration_since(last_near); + for a in events.absent.iter_mut() { + if !a.fired && away >= a.after { + a.fired = true; + eprintln!("presence: absent for {}s", a.after.as_secs()); + if !args.calibrate { run(&a.command, args.wait); } + } + } + if present && events.absent.iter().all(|a| a.fired) { present = false; } + } + } + } + } + ExitCode::SUCCESS +}