presence: BLE proximity events for a kiosk screen
CI / check (push) Successful in 1m34s

A swayidle-shaped daemon: `present <cmd>` runs when a Bluetooth LE device
comes within an RSSI threshold, `absent <secs> <cmd>` 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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-08 21:23:39 +02:00
co-authored by Claude Fable 5.1
commit 8fe75daad6
11 changed files with 1431 additions and 0 deletions
+23
View File
@@ -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
+61
View File
@@ -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 <bendik@lynghaug.me>
# 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
+5
View File
@@ -0,0 +1,5 @@
/target
*.pkg.tar.zst
/pkg
/src/*/
!/src/*.rs
Generated
+850
View File
@@ -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"
+12
View File
@@ -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"] }
+21
View File
@@ -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.
+31
View File
@@ -0,0 +1,31 @@
# Maintainer: bl <bendik@lynghaug.me>
# 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"
}
+99
View File
@@ -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 <seconds> <command>` 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 <addr>` drops the staff phones and the
kiosk's own peripherals once you know their addresses.
## Options
```
--rssi <dBm> sample counts as near at or above this (default -58)
--window <n> samples kept per device (default 5)
--hits <n> near samples out of the window that make a device near (default 3)
--stale <secs> forget samples older than this (default 15)
--adapter <hciN> which adapter (default: the first)
--ignore <addr> 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.
+13
View File
@@ -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"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# publish-registry <dir>: upload every built package in <dir> (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
+297
View File
@@ -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 <secs>`
//! 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 <command> run when a device comes within range\n \
absent <seconds> <command> run once nobody has been in range for <seconds>\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<String>,
/// Addresses to ignore, e.g. staff phones or the kiosk's own peripherals
#[arg(long = "ignore", value_name = "ADDR")]
ignore: Vec<Address>,
/// 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<String>,
}
struct Absent {
after: Duration,
command: String,
fired: bool,
}
struct Events {
present: Vec<String>,
absent: Vec<Absent>,
}
fn parse_events(tokens: &[String]) -> Result<Events, String> {
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<Address, VecDeque<(Instant, i16)>>,
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<Adapter> {
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<Address> = 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
}