feat: rewrite pi to be simple and nix based

This commit is contained in:
Jet Pham 2026-03-08 19:06:08 -07:00
parent b2c8d08bdc
commit c6e726c430
No known key found for this signature in database
28 changed files with 880 additions and 2458 deletions

116
pi/pi-service/src/main.rs Normal file
View file

@ -0,0 +1,116 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use axum::{extract::State, routing::get, Json, Router};
use rppal::gpio::{Gpio, Level, Trigger};
use serde::Serialize;
use tracing::{error, info};
#[derive(Serialize)]
struct StatusResponse {
status: &'static str,
}
fn status_str(is_open: bool) -> &'static str {
if is_open {
"open"
} else {
"closed"
}
}
async fn get_status(State(is_open): State<Arc<AtomicBool>>) -> Json<StatusResponse> {
Json(StatusResponse {
status: status_str(is_open.load(Ordering::Relaxed)),
})
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let gpio_pin: u8 = std::env::var("NOISEBELL_GPIO_PIN")
.unwrap_or_else(|_| "17".into())
.parse()
.context("NOISEBELL_GPIO_PIN must be a valid u8")?;
let debounce_secs: u64 = std::env::var("NOISEBELL_DEBOUNCE_SECS")
.unwrap_or_else(|_| "5".into())
.parse()
.context("NOISEBELL_DEBOUNCE_SECS must be a valid u64")?;
let port: u16 = std::env::var("NOISEBELL_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.context("NOISEBELL_PORT must be a valid u16")?;
let endpoint_url =
std::env::var("NOISEBELL_ENDPOINT_URL").context("NOISEBELL_ENDPOINT_URL is required")?;
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}"))?
.into_input_pullup();
let is_open = Arc::new(AtomicBool::new(pin.read() == Level::Low));
info!(initial_status = status_str(is_open.load(Ordering::Relaxed)), "GPIO initialized");
// Channel to bridge sync GPIO callback -> async notification task
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<bool>();
// Set up async interrupt for state changes
let state_for_interrupt = is_open.clone();
pin.set_async_interrupt(
Trigger::Both,
Some(Duration::from_secs(debounce_secs)),
move |event| {
let new_open = match event.trigger {
Trigger::FallingEdge => true,
Trigger::RisingEdge => false,
_ => return,
};
let was_open = state_for_interrupt.swap(new_open, Ordering::Relaxed);
if was_open != new_open {
let _ = tx.send(new_open);
}
},
)
.context("failed to set GPIO interrupt")?;
// Task that POSTs state changes to the endpoint
tokio::spawn(async move {
let client = reqwest::Client::new();
while let Some(new_open) = rx.recv().await {
let status = status_str(new_open);
info!(status, "state changed");
if let Err(e) = client
.post(&endpoint_url)
.json(&serde_json::json!({ "status": status }))
.send()
.await
{
error!(%e, "failed to notify endpoint");
}
}
});
let app = Router::new()
.route("/status", get(get_status))
.with_state(is_open);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.context(format!("failed to bind to port {port}"))?;
info!(port, "listening");
axum::serve(listener, app).await.context("server error")?;
Ok(())
}