From 1daec9618c6410d7296906eea3b02abce8bd0643 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Fri, 9 Oct 2026 14:18:15 +0200 Subject: [PATCH] Config file, user service, and a build container that can see bluez `--config` reads the same directives as the command line, one per line with the rest of the line as the command, so a unit can carry the kiosk's events without shell quoting. presence.service is a systemd --user unit that runs it from ~/.config/presence/config, restarts on failure, and only starts when the file exists; config.example is the starting point. The x86_64 release job failed on makepkg's runtime dependency check because bluez was not in the container. It is now. Co-Authored-By: Claude Fable 5.1 --- .gitea/workflows/release.yml | 3 +- PKGBUILD | 2 + README.md | 23 +++ config.example | 14 ++ presence.service | 19 +++ src/main.rs | 296 ++++++++++++++++++++++++++--------- 6 files changed, 284 insertions(+), 73 deletions(-) create mode 100644 config.example create mode 100644 presence.service diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 2532990..f60e927 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -18,12 +18,13 @@ env: jobs: # runner-1 (Docker on ergo): Arch container on the ubuntu-latest label. + # bluez is a runtime depend, but makepkg checks those too x86_64: runs-on: ubuntu-latest container: archlinux:latest steps: - name: Prepare container - run: pacman -Syu --noconfirm --needed base-devel git rust dbus + run: pacman -Syu --noconfirm --needed base-devel git rust dbus bluez - name: Checkout uses: builtin:checkout - name: Check tag, PKGBUILD and Cargo.toml agree diff --git a/PKGBUILD b/PKGBUILD index a30add6..c70f4ff 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -28,4 +28,6 @@ package() { 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" + install -Dm644 config.example "$pkgdir/usr/share/doc/presence/config.example" + install -Dm644 presence.service "$pkgdir/usr/lib/systemd/user/presence.service" } diff --git a/README.md b/README.md index 6bbcc69..cf52fa8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,29 @@ kiosk's own peripherals once you know their addresses. 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. +## Config file and user service + +The same words, one per line, with the rest of the line as the command and no +quoting rules. The package ships `/usr/share/doc/presence/config.example`: + +``` +rssi -58 +present swaymsg "output * power on" +absent 600 swaymsg "output * power off" +``` + +`presence --config ~/.config/presence/config` reads it; command-line options +override the file, events from both are used. A `systemd --user` unit is +installed that runs exactly that, so as the kiosk user: + +``` +mkdir -p ~/.config/presence && cp /usr/share/doc/presence/config.example ~/.config/presence/config +systemctl --user enable --now presence +``` + +It restarts on failure and only starts when the config exists. Starting it +from the sway config with `exec presence ...` works just as well; pick one. + ## Requirements `bluetoothd` must be running (`systemctl enable --now bluetooth`). BlueZ's diff --git a/config.example b/config.example new file mode 100644 index 0000000..28b3298 --- /dev/null +++ b/config.example @@ -0,0 +1,14 @@ +# ~/.config/presence/config: one directive per line. The rest of the line +# after the keyword (and the seconds, for absent) is the command, verbatim. +# Pick rssi from `presence --calibrate` at the installed screen; -58 is a +# starting point, not a measurement. + +rssi -58 +# window 5 +# hits 3 +# stale 15 +# adapter hci0 +# ignore AA:BB:CC:DD:EE:FF + +present swaymsg "output * power on" +absent 600 swaymsg "output * power off" diff --git a/presence.service b/presence.service new file mode 100644 index 0000000..473b4d9 --- /dev/null +++ b/presence.service @@ -0,0 +1,19 @@ +# systemd --user unit. Enable it as the kiosk user once ~/.config/presence/config +# exists (see /usr/share/doc/presence/config.example): +# systemctl --user enable --now presence +# Commands run with the user's environment as the manager sees it; swaymsg +# finds the sway socket by itself, other tools may need +# exec systemctl --user import-environment WAYLAND_DISPLAY SWAYSOCK +# in the sway config. +[Unit] +Description=BLE proximity events for the screen +Documentation=file:///usr/share/doc/presence/README.md +ConditionPathExists=%E/presence/config + +[Service] +ExecStart=/usr/bin/presence --config %E/presence/config +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/src/main.rs b/src/main.rs index 16e4edf..23394e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ //! 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 +//! 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. //! @@ -14,6 +14,7 @@ use bluer::{Adapter, AdapterEvent, Address, DiscoveryFilter, DiscoveryTransport, use clap::Parser; use futures::StreamExt; use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; use std::process::ExitCode; use std::time::{Duration, Instant}; use tokio::process::Command; @@ -25,24 +26,29 @@ use tokio::process::Command; 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\"'" + CONFIG FILE (--config): one directive per line, the same words without\n\ + quoting, the rest of the line being the command:\n \ + rssi -58\n \ + present swaymsg \"output * power on\"\n \ + absent 600 swaymsg \"output * power off\"\n\n\ + Command-line options override the file; events from both are used." )] 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, + /// Read options and events from this file (see CONFIG FILE below) + #[arg(long, value_name = "FILE")] + config: Option, + /// RSSI at or above which a sample counts as near, in dBm [default: -58] + #[arg(long, allow_hyphen_values = true)] + rssi: Option, + /// Samples kept per device [default: 5] + #[arg(long)] + window: Option, + /// Samples at or above --rssi (out of --window) that make a device near [default: 3] + #[arg(long)] + hits: Option, + /// Forget samples older than this many seconds [default: 15] + #[arg(long)] + stale: Option, /// Bluetooth adapter to use (default: the first one) #[arg(long)] adapter: Option, @@ -66,35 +72,64 @@ struct Absent { fired: bool, } -struct Events { +/// Everything the loop needs, after the config file and the command line +/// have been merged. +struct Settings { + rssi: i16, + window: usize, + hits: usize, + stale: Duration, + adapter: Option, + ignore: HashSet
, + wait: bool, present: Vec, absent: Vec, } -fn parse_events(tokens: &[String]) -> Result { - let mut ev = Events { - present: Vec::new(), - absent: Vec::new(), - }; +impl Default for Settings { + fn default() -> Self { + Settings { + rssi: -58, + window: 5, + hits: 3, + stale: Duration::from_secs(15), + adapter: None, + ignore: HashSet::new(), + wait: false, + present: Vec::new(), + absent: Vec::new(), + } + } +} + +fn absent(secs: &str, command: &str) -> Result { + let secs: u64 = secs + .parse() + .map_err(|_| format!("absent: bad seconds {secs:?}"))?; + if command.is_empty() { + return Err("absent: missing command".into()); + } + Ok(Absent { + after: Duration::from_secs(secs), + command: command.to_string(), + fired: true, + }) +} + +/// swayidle-style positional events from the command line. +fn parse_events(tokens: &[String], s: &mut Settings) -> Result<(), String> { 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()); + s.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, - }); + s.absent.push(absent(secs, cmd)?); i += 3; } other => { @@ -104,7 +139,86 @@ fn parse_events(tokens: &[String]) -> Result { } } } - Ok(ev) + Ok(()) +} + +/// One directive per line; the rest of the line after the keyword (and the +/// seconds, for absent) is the command, taken verbatim, so no quoting rules. +fn parse_config(text: &str, s: &mut Settings) -> Result<(), String> { + for (n, raw) in text.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let at = |e: String| format!("line {}: {e}", n + 1); + let (key, rest) = line.split_once(char::is_whitespace).unwrap_or((line, "")); + let rest = rest.trim(); + let num = |what: &str| { + rest.parse::() + .map_err(|_| at(format!("{what}: bad number {rest:?}"))) + }; + match key { + "rssi" => { + s.rssi = rest + .parse() + .map_err(|_| at(format!("rssi: bad dBm {rest:?}")))? + } + "window" => s.window = num("window")? as usize, + "hits" => s.hits = num("hits")? as usize, + "stale" => s.stale = Duration::from_secs(num("stale")?), + "adapter" => s.adapter = Some(rest.to_string()), + "ignore" => { + s.ignore + .insert(rest.parse().map_err(|e| at(format!("ignore: {e}")))?); + } + "wait" => s.wait = true, + "present" => { + if rest.is_empty() { + return Err(at("present: missing command".into())); + } + s.present.push(rest.to_string()); + } + "absent" => { + let (secs, cmd) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + s.absent.push(absent(secs, cmd.trim()).map_err(at)?); + } + other => return Err(at(format!("unknown directive {other:?}"))), + } + } + Ok(()) +} + +fn settings(args: &Args) -> Result { + let mut s = Settings::default(); + if let Some(path) = &args.config { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + parse_config(&text, &mut s).map_err(|e| format!("{}: {e}", path.display()))?; + } + if let Some(v) = args.rssi { + s.rssi = v; + } + if let Some(v) = args.window { + s.window = v; + } + if let Some(v) = args.hits { + s.hits = v; + } + if let Some(v) = args.stale { + s.stale = Duration::from_secs(v); + } + if args.adapter.is_some() { + s.adapter = args.adapter.clone(); + } + s.ignore.extend(args.ignore.iter().copied()); + s.wait |= args.wait; + parse_events(&args.events, &mut s)?; + if s.hits == 0 || s.hits > s.window { + return Err("hits must be between 1 and window".into()); + } + if !args.calibrate && s.present.is_empty() && s.absent.is_empty() { + return Err("no events given and not --calibrate; nothing to do".into()); + } + Ok(s) } fn run(command: &str, wait: bool) { @@ -193,24 +307,15 @@ async fn open_adapter(name: Option<&str>) -> bluer::Result { #[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, + let mut s = match settings(&args) { + Ok(s) => s, 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 { + let adapter = match open_adapter(s.adapter.as_deref()).await { Ok(a) => a, Err(e) => { eprintln!("presence: bluetooth: {e} (is bluetoothd running?)"); @@ -218,7 +323,7 @@ async fn main() -> ExitCode { } }; let mut stream = match adapter.discover_devices_with_changes().await { - Ok(s) => s, + Ok(st) => st, Err(e) => { eprintln!("presence: discovery: {e}"); return ExitCode::from(1); @@ -227,18 +332,18 @@ async fn main() -> ExitCode { eprintln!( "presence: scanning on {} (near = {} of {} samples >= {} dBm, samples expire after {}s)", adapter.name(), - args.hits, - args.window, - args.rssi, - args.stale + s.hits, + s.window, + s.rssi, + s.stale.as_secs() ); let mut samples = Samples { per_device: HashMap::new(), - window: args.window, - hits: args.hits, - threshold: args.rssi, - stale: Duration::from_secs(args.stale), + window: s.window, + hits: s.hits, + threshold: s.rssi, + stale: s.stale, }; let started = Instant::now(); let mut present = false; @@ -249,8 +354,11 @@ async fn main() -> ExitCode { 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 Some(AdapterEvent::DeviceAdded(addr)) = ev else { + if ev.is_none() { eprintln!("presence: discovery stream ended"); break; } + continue; + }; + if s.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(); @@ -263,35 +371,79 @@ async fn main() -> ExitCode { _ = tick.tick() => { let now = Instant::now(); samples.purge(now); - let near = samples.anyone_near(now); - if near { + if samples.anyone_near(now) { 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)) { + if !present { present = true; - if !args.calibrate { - for cmd in &events.present { run(cmd, args.wait); } + // Fire `present` only after at least one absence has been + // announced, so a phone flickering around the threshold + // does not re-run it. With no absent events, every arrival fires. + if s.absent.is_empty() || s.absent.iter().any(|a| a.fired) { + eprintln!("presence: present"); + if !args.calibrate { + for cmd in &s.present { run(cmd, s.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; } + for a in s.absent.iter_mut() { a.fired = false; } } } else { let away = now.duration_since(last_near); - for a in events.absent.iter_mut() { + for a in s.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 !args.calibrate { run(&a.command, s.wait); } } } - if present && events.absent.iter().all(|a| a.fired) { present = false; } + if present && s.absent.iter().all(|a| a.fired) { present = false; } } } } } ExitCode::SUCCESS } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_directives() { + let mut s = Settings::default(); + parse_config( + "# kiosk\nrssi -62\nwindow 6\nhits 4\nstale 20\nwait\nignore AA:BB:CC:DD:EE:FF\n\ + present swaymsg \"output * power on\"\nabsent 600 swaymsg \"output * power off\"\n", + &mut s, + ) + .unwrap(); + assert_eq!( + (s.rssi, s.window, s.hits, s.stale.as_secs(), s.wait), + (-62, 6, 4, 20, true) + ); + assert_eq!(s.ignore.len(), 1); + assert_eq!(s.present, vec!["swaymsg \"output * power on\""]); + assert_eq!(s.absent[0].after.as_secs(), 600); + assert_eq!(s.absent[0].command, "swaymsg \"output * power off\""); + } + + #[test] + fn config_errors_name_the_line() { + let mut s = Settings::default(); + let err = parse_config("rssi -58\nabsent soon foo\n", &mut s).unwrap_err(); + assert!(err.starts_with("line 2: absent: bad seconds"), "{err}"); + let err = parse_config("timeout 5 x\n", &mut s).unwrap_err(); + assert!(err.contains("unknown directive"), "{err}"); + } + + #[test] + fn cli_events() { + let mut s = Settings::default(); + let toks: Vec = ["present", "a", "absent", "30", "b"] + .map(String::from) + .into(); + parse_events(&toks, &mut s).unwrap(); + assert_eq!(s.present, vec!["a"]); + assert_eq!(s.absent[0].after.as_secs(), 30); + assert!(parse_events(&["absent".to_string()], &mut s).is_err()); + } +}