feat: add remote, with rss, cache, discord, and zulip

This commit is contained in:
Jet Pham 2026-03-09 23:08:01 -07:00
parent 50ec63a474
commit 83baab68e0
No known key found for this signature in database
32 changed files with 6615 additions and 40 deletions

View file

@ -6,10 +6,12 @@ edition = "2021"
[dependencies]
anyhow = "1.0"
axum = "0.8"
libc = "0.2"
reqwest = { version = "0.12", features = ["json"] }
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"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -3,7 +3,10 @@ use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use axum::{extract::State, routing::get, Json, Router};
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 tracing::{error, info, warn};
@ -11,6 +14,19 @@ use tracing::{error, info, warn};
struct AppState {
is_open: AtomicBool,
last_changed: AtomicU64,
started_at: u64,
gpio_pin: u8,
active_low: bool,
commit: String,
inbound_api_key: String,
}
fn validate_bearer(headers: &HeaderMap, expected: &str) -> bool {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(|v| v.strip_prefix("Bearer ").unwrap_or("") == expected)
.unwrap_or(false)
}
#[derive(Serialize)]
@ -19,6 +35,30 @@ struct StatusResponse {
timestamp: u64,
}
#[derive(Serialize)]
struct GpioInfo {
pin: u8,
active_low: bool,
pull: &'static str,
open_level: &'static str,
current_raw_level: &'static str,
}
#[derive(Serialize)]
struct InfoResponse {
uptime_secs: u64,
started_at: u64,
cpu_temp_celsius: Option<f64>,
memory_available_kb: Option<u64>,
memory_total_kb: Option<u64>,
disk_total_bytes: Option<u64>,
disk_available_bytes: Option<u64>,
load_average: Option<[f64; 3]>,
nixos_version: Option<String>,
commit: String,
gpio: GpioInfo,
}
fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -34,11 +74,102 @@ fn status_str(is_open: bool) -> &'static str {
}
}
async fn get_status(State(state): State<Arc<AppState>>) -> Json<StatusResponse> {
Json(StatusResponse {
fn read_cpu_temp() -> Option<f64> {
std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.map(|m| m / 1000.0)
}
fn read_meminfo_field(contents: &str, field: &str) -> Option<u64> {
contents
.lines()
.find(|l| l.starts_with(field))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse().ok())
}
fn read_disk_usage() -> Option<(u64, u64)> {
let path = std::ffi::CString::new("/").ok()?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let ret = unsafe { libc::statvfs(path.as_ptr(), &mut stat) };
if ret != 0 {
return None;
}
let block_size = stat.f_frsize as u64;
Some((
stat.f_blocks * block_size,
stat.f_bavail * block_size,
))
}
fn read_load_average() -> Option<[f64; 3]> {
let contents = std::fs::read_to_string("/proc/loadavg").ok()?;
let mut parts = contents.split_whitespace();
Some([
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
])
}
fn read_nixos_version() -> Option<String> {
std::fs::read_to_string("/run/current-system/nixos-version")
.ok()
.map(|s| s.trim().to_string())
}
async fn get_status(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<StatusResponse>, StatusCode> {
if !validate_bearer(&headers, &state.inbound_api_key) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(Json(StatusResponse {
status: status_str(state.is_open.load(Ordering::Relaxed)),
timestamp: state.last_changed.load(Ordering::Relaxed),
})
}))
}
async fn get_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<InfoResponse>, StatusCode> {
if !validate_bearer(&headers, &state.inbound_api_key) {
return Err(StatusCode::UNAUTHORIZED);
}
let meminfo = std::fs::read_to_string("/proc/meminfo").ok();
let disk = read_disk_usage();
let is_open = state.is_open.load(Ordering::Relaxed);
let raw_level = match (state.active_low, is_open) {
(true, true) | (false, false) => "low",
_ => "high",
};
Ok(Json(InfoResponse {
uptime_secs: unix_timestamp() - state.started_at,
started_at: state.started_at,
cpu_temp_celsius: read_cpu_temp(),
memory_available_kb: meminfo
.as_deref()
.and_then(|m| read_meminfo_field(m, "MemAvailable:")),
memory_total_kb: meminfo
.as_deref()
.and_then(|m| read_meminfo_field(m, "MemTotal:")),
disk_total_bytes: disk.map(|(total, _)| total),
disk_available_bytes: disk.map(|(_, avail)| avail),
load_average: read_load_average(),
nixos_version: read_nixos_version(),
commit: state.commit.clone(),
gpio: GpioInfo {
pin: state.gpio_pin,
active_low: state.active_low,
pull: if state.active_low { "up" } else { "down" },
open_level: if state.active_low { "low" } else { "high" },
current_raw_level: raw_level,
},
}))
}
#[tokio::main]
@ -65,6 +196,9 @@ async fn main() -> Result<()> {
let endpoint_url =
std::env::var("NOISEBELL_ENDPOINT_URL").context("NOISEBELL_ENDPOINT_URL is required")?;
let api_key =
std::env::var("NOISEBELL_API_KEY").context("NOISEBELL_API_KEY is required")?;
let retry_attempts: u32 = std::env::var("NOISEBELL_RETRY_ATTEMPTS")
.unwrap_or_else(|_| "3".into())
.parse()
@ -80,13 +214,17 @@ async fn main() -> Result<()> {
.parse()
.context("NOISEBELL_HTTP_TIMEOUT_SECS must be a valid u64")?;
let bind_address = std::env::var("NOISEBELL_BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".into());
let bind_address =
std::env::var("NOISEBELL_BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".into());
let active_low: bool = std::env::var("NOISEBELL_ACTIVE_LOW")
.unwrap_or_else(|_| "true".into())
.parse()
.context("NOISEBELL_ACTIVE_LOW must be true or false")?;
let inbound_api_key = std::env::var("NOISEBELL_INBOUND_API_KEY")
.context("NOISEBELL_INBOUND_API_KEY is required")?;
info!(gpio_pin, debounce_secs, port, %endpoint_url, "starting noisebell");
let gpio = Gpio::new().context("failed to initialize GPIO")?;
@ -101,9 +239,18 @@ async fn main() -> Result<()> {
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());
let state = Arc::new(AppState {
is_open: AtomicBool::new(initial_open),
last_changed: AtomicU64::new(unix_timestamp()),
last_changed: AtomicU64::new(now),
started_at: now,
gpio_pin,
active_low,
commit,
inbound_api_key,
});
info!(initial_status = status_str(initial_open), "GPIO initialized");
@ -111,7 +258,7 @@ async fn main() -> Result<()> {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, u64)>();
// Sync initial state with the cache on startup
let _ = tx.send((initial_open, unix_timestamp()));
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
@ -130,7 +277,9 @@ async fn main() -> Result<()> {
let was_open = state_for_interrupt.is_open.swap(new_open, Ordering::Relaxed);
if was_open != new_open {
let timestamp = unix_timestamp();
state_for_interrupt.last_changed.store(timestamp, Ordering::Relaxed);
state_for_interrupt
.last_changed
.store(timestamp, Ordering::Relaxed);
let _ = tx.send((new_open, timestamp));
}
},
@ -150,7 +299,12 @@ async fn main() -> Result<()> {
let payload = serde_json::json!({ "status": status, "timestamp": timestamp });
for attempt in 0..=retry_attempts {
let result = client.post(&endpoint_url).json(&payload).send().await;
let result = client
.post(&endpoint_url)
.bearer_auth(&api_key)
.json(&payload)
.send()
.await;
match result {
Ok(resp) if resp.status().is_success() => break,
_ => {
@ -175,6 +329,7 @@ async fn main() -> Result<()> {
let app = Router::new()
.route("/", get(get_status))
.route("/info", get(get_info))
.with_state(state);
let listener = tokio::net::TcpListener::bind((&*bind_address, port))
@ -183,9 +338,27 @@ async fn main() -> Result<()> {
info!(port, "listening");
let shutdown = tokio::signal::ctrl_c();
// Start watchdog task if systemd watchdog is enabled
if let Ok(usec_str) = std::env::var("WATCHDOG_USEC") {
if let Ok(usec) = usec_str.parse::<u64>() {
let period = Duration::from_micros(usec / 2);
tokio::spawn(async move {
loop {
sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]).ok();
tokio::time::sleep(period).await;
}
});
}
}
sd_notify::notify(false, &[sd_notify::NotifyState::Ready]).ok();
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("failed to register SIGTERM handler")?;
axum::serve(listener, app)
.with_graceful_shutdown(async { shutdown.await.ok(); })
.with_graceful_shutdown(async move {
sigterm.recv().await;
})
.await
.context("server error")?;