diff --git a/.gitignore b/.gitignore index 1b56d0f..a27f459 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ remote/target +pi/pi-service/target .direnv diff --git a/pi/configuration.nix b/pi/configuration.nix index d2dfec7..9108d69 100644 --- a/pi/configuration.nix +++ b/pi/configuration.nix @@ -22,14 +22,8 @@ # Decrypted at runtime by agenix age.secrets.tailscale-auth-key.file = ./secrets/tailscale-auth-key.age; - age.secrets.api-key = { - file = ./secrets/api-key.age; - owner = "noisebell"; - }; - age.secrets.inbound-api-key = { - file = ./secrets/inbound-api-key.age; - owner = "noisebell"; - }; + age.secrets.api-key.file = ./secrets/api-key.age; + age.secrets.inbound-api-key.file = ./secrets/inbound-api-key.age; services.noisebell = { enable = true; diff --git a/pi/flake.nix b/pi/flake.nix index 781f3d7..1f5531a 100644 --- a/pi/flake.nix +++ b/pi/flake.nix @@ -97,19 +97,6 @@ }; config = lib.mkIf cfg.enable { - users.users.noisebell = { - isSystemUser = true; - group = "noisebell"; - extraGroups = [ "gpio" ]; - }; - users.groups.noisebell = {}; - users.groups.gpio = {}; - - services.udev.extraRules = '' - KERNEL=="gpiomem", GROUP="gpio", MODE="0660" - KERNEL=="gpiochip[0-9]*", GROUP="gpio", MODE="0660" - ''; - systemd.services.noisebell = let bin = "${noisebell.packages.aarch64-linux.default}/bin/noisebell"; in { @@ -144,20 +131,6 @@ WatchdogSec = cfg.watchdogSecs; Restart = "on-failure"; RestartSec = cfg.restartDelaySecs; - User = "noisebell"; - Group = "noisebell"; - AmbientCapabilities = lib.optionals (cfg.port < 1024) [ "CAP_NET_BIND_SERVICE" ]; - NoNewPrivileges = true; - ProtectSystem = "strict"; - ProtectHome = true; - PrivateTmp = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectControlGroups = true; - RestrictSUIDSGID = true; - MemoryDenyWriteExecute = true; - DevicePolicy = "closed"; - DeviceAllow = [ "char-gpiomem rw" "char-gpiochip rw" ]; }; }; }; diff --git a/pi/pi-service/Cargo.lock b/pi/pi-service/Cargo.lock index 9aa83ba..3156439 100644 --- a/pi/pi-service/Cargo.lock +++ b/pi/pi-service/Cargo.lock @@ -81,6 +81,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -217,6 +223,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gpiod-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a60e3beb5444643d049a3f8769b47ce246ec1f57e6cd1aed1e417d57a47110" +dependencies = [ + "nix", +] + [[package]] name = "http" version = "1.4.0" @@ -526,6 +541,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "noisebell" version = "0.1.0" @@ -534,11 +560,11 @@ dependencies = [ "axum", "libc", "reqwest", - "rppal", "sd-notify", "serde", "serde_json", "tokio", + "tokio-gpiod", "tracing", "tracing-subscriber", ] @@ -771,15 +797,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rppal" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ce3b019009cff02cb6b0e96e7cc2e5c5b90187dc1a490f8ef1521d0596b026" -dependencies = [ - "libc", -] - [[package]] name = "rustc-hash" version = "2.1.1" @@ -1068,6 +1085,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-gpiod" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce15fa0021a7acacd2be506f72aeb5044a0a8b53d684963f133b37ace5c57f47" +dependencies = [ + "gpiod-core", + "libc", + "tokio", +] + [[package]] name = "tokio-macros" version = "2.6.1" @@ -1111,7 +1139,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", diff --git a/pi/pi-service/Cargo.toml b/pi/pi-service/Cargo.toml index 65c8764..be7ae80 100644 --- a/pi/pi-service/Cargo.toml +++ b/pi/pi-service/Cargo.toml @@ -8,10 +8,10 @@ anyhow = "1.0" axum = "0.8" libc = "0.2" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -rppal = "0.22" sd-notify = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "time"] } +tokio-gpiod = "0.3.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/pi/pi-service/src/main.rs b/pi/pi-service/src/main.rs index 6efdc12..f1c62c6 100644 --- a/pi/pi-service/src/main.rs +++ b/pi/pi-service/src/main.rs @@ -7,8 +7,8 @@ use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use axum::routing::get; use axum::{Json, Router}; -use rppal::gpio::{Gpio, Level, Trigger}; use serde::Serialize; +use tokio_gpiod::{Bias, Chip, Edge, EdgeDetect, Options}; use tracing::{error, info, warn}; struct AppState { @@ -178,10 +178,10 @@ async fn main() -> Result<()> { .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - let gpio_pin: u8 = std::env::var("NOISEBELL_GPIO_PIN") + let gpio_pin: u32 = std::env::var("NOISEBELL_GPIO_PIN") .unwrap_or_else(|_| "17".into()) .parse() - .context("NOISEBELL_GPIO_PIN must be a valid u8")?; + .context("NOISEBELL_GPIO_PIN must be a valid u32")?; let debounce_secs: u64 = std::env::var("NOISEBELL_DEBOUNCE_SECS") .unwrap_or_else(|_| "5".into()) @@ -227,18 +227,32 @@ async fn main() -> Result<()> { info!(gpio_pin, debounce_secs, port, %endpoint_url, "starting noisebell"); - let gpio = Gpio::new().context("failed to initialize GPIO")?; - let pin = gpio - .get(gpio_pin) - .context(format!("failed to get GPIO pin {gpio_pin}"))?; - let pin = if active_low { - pin.into_input_pullup() - } else { - pin.into_input_pulldown() - }; + let chip = Chip::new("gpiochip0") + .await + .context("failed to open gpiochip0")?; + + let bias = if active_low { Bias::PullUp } else { Bias::PullDown }; + + // Request the line with edge detection for monitoring + let opts = Options::input([gpio_pin]) + .bias(bias) + .edge(EdgeDetect::Both) + .consumer("noisebell"); + let mut inputs = chip + .request_lines(opts) + .await + .context(format!("failed to request GPIO line {gpio_pin}"))?; + + // Read initial value + let initial_values = inputs + .get_values([false]) + .await + .context("failed to read initial GPIO value")?; + // Value is true when line is active. With Active::High (default), + // true means the physical level is high. + let initial_high = initial_values[0]; + let initial_open = if active_low { !initial_high } else { initial_high }; - let open_level = if active_low { Level::Low } else { Level::High }; - let initial_open = pin.read() == open_level; let now = unix_timestamp(); let commit = std::env::var("NOISEBELL_COMMIT").unwrap_or_else(|_| "unknown".into()); @@ -247,7 +261,7 @@ async fn main() -> Result<()> { is_open: AtomicBool::new(initial_open), last_changed: AtomicU64::new(now), started_at: now, - gpio_pin, + gpio_pin: gpio_pin as u8, active_low, commit, inbound_api_key, @@ -260,31 +274,45 @@ async fn main() -> Result<()> { // Sync initial state with the cache on startup let _ = tx.send((initial_open, now)); - let state_for_interrupt = state.clone(); - // pin must live for the entire program — rppal runs interrupts on a background - // thread tied to the InputPin. If pin drops, the interrupt thread is joined and - // monitoring stops. We move it into a binding that lives until main() returns. - let mut _pin = pin; - _pin.set_async_interrupt( - Trigger::Both, - Some(Duration::from_secs(debounce_secs)), - move |event| { - let new_open = match event.trigger { - Trigger::FallingEdge => active_low, - Trigger::RisingEdge => !active_low, - _ => return, + // Spawn async edge detection task + let state_for_edges = state.clone(); + let edge_tx = tx.clone(); + let edge_handle = tokio::spawn(async move { + let mut last_event_time = std::time::Instant::now(); + let debounce = Duration::from_secs(debounce_secs); + + loop { + let event = match inputs.read_event().await { + Ok(event) => event, + Err(e) => { + error!(error = %e, "failed to read GPIO event"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } }; - let was_open = state_for_interrupt.is_open.swap(new_open, Ordering::Relaxed); + + // Software debounce + if last_event_time.elapsed() < debounce { + continue; + } + last_event_time = std::time::Instant::now(); + + let new_open = match event.edge { + Edge::Falling => active_low, + Edge::Rising => !active_low, + }; + + let was_open = state_for_edges.is_open.swap(new_open, Ordering::Relaxed); if was_open != new_open { let timestamp = unix_timestamp(); - state_for_interrupt + state_for_edges .last_changed .store(timestamp, Ordering::Relaxed); - let _ = tx.send((new_open, timestamp)); + let _ = edge_tx.send((new_open, timestamp)); } - }, - ) - .context("failed to set GPIO interrupt")?; + } + }); + drop(tx); // Drop original sender so rx closes when edge_handle is dropped let notify_handle = tokio::spawn(async move { let client = reqwest::Client::builder() @@ -363,9 +391,7 @@ async fn main() -> Result<()> { .context("server error")?; info!("shutting down, draining notification queue"); - // Drop the interrupt to stop producing new messages, then wait - // for the notification task to drain remaining messages. - drop(_pin); + edge_handle.abort(); let _ = notify_handle.await; info!("shutdown complete");