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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-09 14:18:15 +02:00
co-authored by Claude Fable 5.1
parent e84b88ab55
commit 1daec9618c
6 changed files with 284 additions and 73 deletions
+2 -1
View File
@@ -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
+2
View File
@@ -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"
}
+23
View File
@@ -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
+14
View File
@@ -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"
+19
View File
@@ -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
+222 -70
View File
@@ -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 <secs>`
//! 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 <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\"'"
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<PathBuf>,
/// RSSI at or above which a sample counts as near, in dBm [default: -58]
#[arg(long, allow_hyphen_values = true)]
rssi: Option<i16>,
/// Samples kept per device [default: 5]
#[arg(long)]
window: Option<usize>,
/// Samples at or above --rssi (out of --window) that make a device near [default: 3]
#[arg(long)]
hits: Option<usize>,
/// Forget samples older than this many seconds [default: 15]
#[arg(long)]
stale: Option<u64>,
/// Bluetooth adapter to use (default: the first one)
#[arg(long)]
adapter: Option<String>,
@@ -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<String>,
ignore: HashSet<Address>,
wait: bool,
present: Vec<String>,
absent: Vec<Absent>,
}
fn parse_events(tokens: &[String]) -> Result<Events, String> {
let mut ev = Events {
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<Absent, String> {
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<Events, String> {
}
}
}
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::<u64>()
.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<Settings, String> {
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<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,
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<Address> = 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); }
}
for a in events.absent.iter_mut() { a.fired = false; }
// 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");
} else if !present {
present = true;
for a in events.absent.iter_mut() { a.fired = false; }
if !args.calibrate {
for cmd in &s.present { run(cmd, s.wait); }
}
}
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<String> = ["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());
}
}