Compare commits
2
Commits
v0.1.0
...
a80c874842
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a80c874842 | ||
|
|
1daec9618c |
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -548,7 +548,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "presence"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"bluer",
|
||||
"clap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "presence"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "BLE proximity events for a kiosk screen, swayidle style"
|
||||
license = "MIT"
|
||||
edition = "2024"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# builds in place into ./target (gitignored) instead.
|
||||
|
||||
pkgname=presence
|
||||
pkgver=0.1.0
|
||||
pkgver=0.1.1
|
||||
pkgrel=1
|
||||
pkgdesc="BLE proximity events for a kiosk screen, swayidle style"
|
||||
arch=('x86_64' 'aarch64')
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user