feat: remove vercel web frontend part and rename to pi

This commit is contained in:
Jet Pham 2026-03-02 19:22:37 -08:00
parent dff2e96947
commit b2c8d08bdc
No known key found for this signature in database
46 changed files with 0 additions and 4965 deletions

2
pi/.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

4
pi/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
noisebell.service
/logs
.env

2375
pi/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

26
pi/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "noisebell"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
tokio = { version = "1.45.1", features = ["full"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
rppal = "0.22.1"
serde = { version = "1.0.219", features = ["derive"] }
tracing-appender = "0.2.3"
axum = { version = "0.8.4", features = ["ws"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["fs"] }
serde_json = "1.0.140"
regex = "1.11.1"
chrono = { version = "0.4.41", features = ["serde"] }
futures = "0.3.31"
futures-util = "0.3.31"
url = "2.5.4"
thiserror = "1.0"
reqwest = { version = "0.12", features = ["json"] }
toml = "0.9.5"
dotenvy = "0.15.7"

180
pi/README.md Normal file
View file

@ -0,0 +1,180 @@
# <img src="media/noisebell%20logo.svg" width="100" alt="Noisebell Logo" style="vertical-align: middle; margin-right: 20px;"> Noisebell
A switch monitoring system that detects circuit state changes via GPIO and notifies configured HTTP endpoints via POST requests.
This is build by [Jet Pham][jetpham] to be used at Noisebridge to replace their old discord status bot
## Features
- GPIO circuit monitoring with configurable pin
- HTTP endpoint notifications via POST requests
- Daily rotating log files
- Cross-compilation support for Raspberry Pi deployment
- Software debouncing to prevent noisy switch detection
- Concurrent HTTP notifications for improved performance
- Comprehensive logging and error reporting
- Web-based monitor for testing (no physical hardware required)
- **Unified configuration system** with environment variable support
## Configuration
Noisebell uses environment variables for all configuration settings. Copy `env.example` to `.env` and modify the values as needed.
### Environment Variables
All configuration is handled through environment variables. Here are the available options:
#### GPIO Configuration
- `NOISEBELL_GPIO_PIN` (default: 17) - GPIO pin number for circuit monitoring
- `NOISEBELL_GPIO_DEBOUNCE_DELAY_SECS` (default: 5) - Debounce delay in seconds
#### Web Monitor Configuration
- `NOISEBELL_WEB_MONITOR_PORT` (default: 8080) - Port for web monitor server
- `NOISEBELL_WEB_MONITOR_ENABLED` (default: true) - Enable/disable web monitor
#### Logging Configuration
- `NOISEBELL_LOGGING_LEVEL` (default: info) - Log level (trace, debug, info, warn, error)
- `NOISEBELL_LOGGING_FILE_PATH` (default: logs/noisebell.log) - Log file path
- `NOISEBELL_LOGGING_MAX_BUFFERED_LINES` (default: 10000) - Maximum buffered log lines
#### Monitor Configuration
- `NOISEBELL_MONITOR_TYPE` (default: web) - Monitor type (gpio, web)
#### Endpoint Configuration
- `NOISEBELL_ENDPOINT_URL` (default: https://noisebell.jetpham.com/api/status) - HTTP endpoint URL
- `ENDPOINT_API_KEY` (optional) - API key for Authorization header
- `NOISEBELL_ENDPOINT_TIMEOUT_SECS` (default: 30) - Request timeout in seconds
- `NOISEBELL_ENDPOINT_RETRY_ATTEMPTS` (default: 3) - Number of retry attempts
### GPIO and Physical Tech
We interact directly over a [GPIO pin in a pull-up configuration][gpio-pullup] to read whether a circuit has been closed with a switch. This is an extremely simple circuit that will internally call a callback function when the state of the circuit changes.
When a state change is detected, the system:
1. Logs the circuit state change
2. Sends HTTP POST requests to all configured endpoints
3. Reports success/failure statistics in the logs
## Debouncing
When a switch changes state, it can bounce and create multiple rapid signals. Debouncing adds a delay to wait for the signal to settle, ensuring we only detect one clean state change instead of multiple false ones.
We do debouncing with software via [`set_async_interupt`][rppal-docs] which handles software debounce for us.
### Logging
Logs are stored in a single continuous log file in the `logs` directory
### Endpoint Notifications
When a circuit state change is detected, the system sends HTTP POST requests to the configured endpoint with the following JSON payload:
```json
{
"status": "open"
}
```
The status field will be either `"open"` or `"closed"` (lowercase).
#### Endpoint Configuration
The endpoint is configured using the environment variables listed above. If an API key is provided, it will be included in the `Authorization: Bearer <api_key>` header.
### Web Monitor
A web-based monitor is available for testing without physical hardware. When `NOISEBELL_WEB_MONITOR_ENABLED=true` (default), you can access the monitor at `http://localhost:8080` to manually trigger state changes and test the endpoint notification system.
### Images
<div align="center">
<img src="media/noisebell%20knifeswitch.jpg" width="400" alt="Knife Switch">
<br>
<em>The knife switch used to detect circuit state changes</em>
</div>
<br>
<div align="center">
<img src="media/noisebell%20raspberrypi%20closeup.jpg" width="400" alt="Raspberry Pi Closeup">
<br>
<em>Closeup view of the Raspberry Pi setup</em>
</div>
<br>
<div align="center">
<img src="media/noisebell%20raspberrypi%20with%20porthole.jpg" width="400" alt="Raspberry Pi with Porthole">
<br>
<em>The complete setup showing the Raspberry Pi mounted in a porthole</em>
</div>
## Development
### Requirements
- Rust toolchain (Install [Rust][rust-install])
- Raspberry Pi (tested on [RP02W][rp02w])
- `cross` for cross-compilation (Install [Cross][cross-install])
- Internet connectivity (wifi for the rp02w)
### Local Development (Web Monitor)
For local development and testing, you can run the web-based monitor using the following command:
```bash
# Copy the example environment file
cp env.example .env
# Run the application
cargo run
```
This will start a web server on port 8080. Open your browser and go to [http://localhost:8080](http://localhost:8080) to interact with the web monitor.
This is meant to replace the need for testing on an actual raspberry pi with gpio pins while keeping the terminal clean for logs.
### Deployment
The project includes a deployment script for Raspberry Pi. To deploy, run the deployment script:
```bash
./deploy.sh
```
### Configuration Validation
The application validates all configuration values on startup. If any configuration is invalid, the application will exit with a descriptive error message. Common validation checks include:
- GPIO pin must be between 1-40
- Debounce delay must be greater than 0
- Monitor type must be either "gpio" or "web"
- Port numbers must be valid
- Log levels must be valid (trace, debug, info, warn, error)
### Quick Start
1. **Clone the repository:**
```bash
git clone <repository-url>
cd noisebell
```
2. **Set up environment variables:**
```bash
cp env.example .env
# Edit .env with your configuration
```
3. **Run the application:**
```bash
cargo run
```
[jetpham]: https://jetpham.com/
[gpio-pullup]: https://raspberrypi.stackexchange.com/questions/4569/what-is-a-pull-up-resistor-what-does-it-do-and-why-is-it-needed
[rppal-docs]: https://docs.rs/rppal/latest/rppal/gpio/struct.InputPin.html#method.set_async_interrupt
[rust-install]: https://www.rust-lang.org/tools/install
[rp02w]: https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/
[cross-install]: https://github.com/cross-rs/cross

60
pi/deploy.sh Executable file
View file

@ -0,0 +1,60 @@
#!/bin/bash
# Exit on error
set -e
echo "Building for Raspberry Pi..."
cross build --release --target aarch64-unknown-linux-gnu
# Check if Discord credentials are already set
if [ -z "$DISCORD_TOKEN" ]; then
echo "Please enter your Discord bot token:"
read -s DISCORD_TOKEN
fi
if [ -z "$DISCORD_CHANNEL_ID" ]; then
echo "Please enter your Discord channel ID:"
read -s DISCORD_CHANNEL_ID
fi
# Create service file with credentials
cat > noisebell.service << EOL
[Unit]
Description=Noisebell Discord Notification Service
After=network.target
[Service]
Type=simple
User=noisebridge
WorkingDirectory=/home/noisebridge
Environment=DISCORD_TOKEN=${DISCORD_TOKEN}
Environment=DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
ExecStart=/home/noisebridge/noisebell
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOL
echo "Copying to Raspberry Pi..."
# Debug remote directory status
ssh noisebridge@noisebell.local "pwd && ls -la ~/ && echo 'Directory permissions:' && stat -c '%A %a %n' ~/"
# Remove existing files
ssh noisebridge@noisebell.local "rm -f /home/noisebridge/noisebell /home/noisebridge/noisebell.service"
# Copy files with absolute paths
scp -v target/aarch64-unknown-linux-gnu/release/noisebell noisebridge@noisebell.local:/home/noisebridge/noisebell
scp -v noisebell.service noisebridge@noisebell.local:/home/noisebridge/noisebell.service
echo "Setting up service..."
# Deploy service
ssh noisebridge@noisebell.local "sudo cp /home/noisebridge/noisebell.service /etc/systemd/system/ && \
sudo systemctl daemon-reload && \
sudo systemctl enable noisebell && \
sudo systemctl restart noisebell"
# Clean up local service file
rm noisebell.service
echo "Deployment complete!"
echo "You can check the service status with: ssh noisebridge@noisebell.local 'sudo systemctl status noisebell'"

26
pi/env.example Normal file
View file

@ -0,0 +1,26 @@
# Environment variables for noisebell
# Copy this file to .env and modify as needed
# GPIO Configuration
NOISEBELL_GPIO_PIN=17
NOISEBELL_GPIO_DEBOUNCE_DELAY_SECS=5
# Web Monitor Configuration
NOISEBELL_WEB_MONITOR_PORT=8080
NOISEBELL_WEB_MONITOR_ENABLED=true
# Logging Configuration
NOISEBELL_LOGGING_LEVEL=info
NOISEBELL_LOGGING_FILE_PATH=logs/noisebell.log
NOISEBELL_LOGGING_MAX_BUFFERED_LINES=10000
# Monitor Configuration
NOISEBELL_MONITOR_TYPE=web
# Endpoint Configuration
NOISEBELL_ENDPOINT_URL=https://noisebell.jetpham.com/api/status
NOISEBELL_ENDPOINT_TIMEOUT_SECS=30
NOISEBELL_ENDPOINT_RETRY_ATTEMPTS=3
# API key for endpoint notifications (optional)
ENDPOINT_API_KEY=your_api_key_here

BIN
pi/media/closed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
pi/media/noisebell logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

161
pi/media/noisebell logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
pi/media/open.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

195
pi/src/config.rs Normal file
View file

@ -0,0 +1,195 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use anyhow::Result;
use dotenvy::dotenv;
use tracing::info;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub gpio: GpioConfig,
pub web_monitor: WebMonitorConfig,
pub logging: LoggingConfig,
pub monitor: MonitorConfig,
pub endpoint: EndpointConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpioConfig {
pub pin: u8,
pub debounce_delay_secs: u64,
}
impl GpioConfig {
pub fn from_env() -> Result<Self> {
let pin = std::env::var("NOISEBELL_GPIO_PIN")
.unwrap_or_else(|_| "17".to_string())
.parse::<u8>()
.map_err(|_| anyhow::anyhow!("Invalid GPIO pin number"))?;
let debounce_delay_secs = std::env::var("NOISEBELL_GPIO_DEBOUNCE_DELAY_SECS")
.unwrap_or_else(|_| "5".to_string())
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("Invalid debounce delay"))?;
Ok(Self {
pin,
debounce_delay_secs,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebMonitorConfig {
pub port: u16,
pub enabled: bool,
}
impl WebMonitorConfig {
pub fn from_env() -> Result<Self> {
let port = std::env::var("NOISEBELL_WEB_MONITOR_PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse::<u16>()
.map_err(|_| anyhow::anyhow!("Invalid web monitor port"))?;
let enabled = std::env::var("NOISEBELL_WEB_MONITOR_ENABLED")
.unwrap_or_else(|_| "true".to_string())
.parse::<bool>()
.map_err(|_| anyhow::anyhow!("Invalid web monitor enabled flag"))?;
Ok(Self {
port,
enabled,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
pub level: String,
pub file_path: String,
pub max_buffered_lines: usize,
}
impl LoggingConfig {
pub fn from_env() -> Result<Self> {
let level = std::env::var("NOISEBELL_LOGGING_LEVEL")
.unwrap_or_else(|_| "info".to_string());
let file_path = std::env::var("NOISEBELL_LOGGING_FILE_PATH")
.unwrap_or_else(|_| "logs/noisebell.log".to_string());
let max_buffered_lines = std::env::var("NOISEBELL_LOGGING_MAX_BUFFERED_LINES")
.unwrap_or_else(|_| "10000".to_string())
.parse::<usize>()
.map_err(|_| anyhow::anyhow!("Invalid max buffered lines"))?;
Ok(Self {
level,
file_path,
max_buffered_lines,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitorConfig {
pub monitor_type: String,
}
impl MonitorConfig {
pub fn from_env() -> Result<Self> {
let monitor_type = std::env::var("NOISEBELL_MONITOR_TYPE")
.unwrap_or_else(|_| "web".to_string());
Ok(Self {
monitor_type,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointConfig {
pub url: String,
pub api_key: Option<String>,
pub timeout_secs: u64,
pub retry_attempts: u32,
}
impl EndpointConfig {
pub fn from_env() -> Result<Self> {
let url = std::env::var("NOISEBELL_ENDPOINT_URL")
.unwrap_or_else(|_| "https://noisebell.jetpham.com/api/status".to_string());
let api_key = std::env::var("ENDPOINT_API_KEY").ok();
let timeout_secs = std::env::var("NOISEBELL_ENDPOINT_TIMEOUT_SECS")
.unwrap_or_else(|_| "30".to_string())
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("Invalid endpoint timeout"))?;
let retry_attempts = std::env::var("NOISEBELL_ENDPOINT_RETRY_ATTEMPTS")
.unwrap_or_else(|_| "3".to_string())
.parse::<u32>()
.map_err(|_| anyhow::anyhow!("Invalid retry attempts"))?;
Ok(Self {
url,
api_key,
timeout_secs,
retry_attempts,
})
}
}
impl Config {
pub fn from_env() -> Result<Self> {
Self::load_env()?;
let config = Config {
gpio: GpioConfig::from_env()?,
web_monitor: WebMonitorConfig::from_env()?,
logging: LoggingConfig::from_env()?,
monitor: MonitorConfig::from_env()?,
endpoint: EndpointConfig::from_env()?,
};
Ok(config)
}
pub fn load_env() -> Result<()> {
// Try to load from .env file, but don't fail if it doesn't exist
match dotenv() {
Ok(_) => {
info!("Successfully loaded environment variables from .env file");
Ok(())
}
Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
info!("No .env file found, using system environment variables");
Ok(())
}
Err(e) => {
Err(anyhow::anyhow!("Failed to load .env file: {}", e))
}
}
}
pub fn validate(&self) -> Result<()> {
if self.gpio.pin > 40 {
return Err(anyhow::anyhow!("GPIO pin must be between 1-40"));
}
if self.gpio.debounce_delay_secs <= 0 {
return Err(anyhow::anyhow!("Debounce delay must be greater than 0"));
}
if !["gpio", "web"].contains(&self.monitor.monitor_type.as_str()) {
return Err(anyhow::anyhow!("Unknown monitor type: {}", self.monitor.monitor_type));
}
Ok(())
}
pub fn get_debounce_delay(&self) -> Duration {
Duration::from_secs(self.gpio.debounce_delay_secs)
}
}

View file

@ -0,0 +1,95 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{info, error, warn};
use reqwest::Client;
use tokio::time::{sleep, Duration};
use crate::StatusEvent;
use anyhow::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointConfig {
pub url: String,
pub api_key: Option<String>,
pub timeout_secs: u64,
pub retry_attempts: u32,
}
pub struct EndpointNotifier {
config: EndpointConfig,
client: Client,
}
impl EndpointNotifier {
pub fn new(config: EndpointConfig) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.expect("Failed to create HTTP client");
Self { config, client }
}
pub async fn notify_endpoint(&self, event: StatusEvent) -> Result<()> {
let status = match event {
StatusEvent::Open => "open",
StatusEvent::Closed => "closed",
};
let payload = json!({
"status": status,
});
let mut success = false;
let mut last_error = None;
for attempt in 1..=self.config.retry_attempts {
match self.send_request(&payload).await {
Ok(_) => {
success = true;
break;
}
Err(e) => {
last_error = Some(e);
if attempt < self.config.retry_attempts {
warn!("Attempt {} failed: {}. Retrying...", attempt, last_error.as_ref().unwrap());
sleep(Duration::from_secs(1)).await;
}
}
}
}
if !success {
let error_msg = last_error.unwrap_or_else(|| anyhow::anyhow!("Unknown error"));
error!("Failed to notify endpoint after {} attempts: {}", self.config.retry_attempts, error_msg);
return Err(error_msg);
}
Ok(())
}
async fn send_request(&self, payload: &serde_json::Value) -> Result<()> {
let mut request = self.client
.post(&self.config.url)
.json(payload);
if let Some(api_key) = &self.config.api_key {
request = request.header("Authorization", format!("Bearer {}", api_key));
}
let response = request
.timeout(Duration::from_secs(self.config.timeout_secs))
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"HTTP request failed with status {}: {}",
response.status(),
response.text().await.unwrap_or_else(|_| "Unknown error".to_string())
));
}
Ok(())
}
}

45
pi/src/gpio_monitor.rs Normal file
View file

@ -0,0 +1,45 @@
use std::time::Duration;
use anyhow::{Result, Context};
use crate::{StatusEvent, monitor::Monitor};
pub struct GpioMonitor {
pin: rppal::gpio::InputPin,
debounce_delay: Duration,
}
impl GpioMonitor {
pub fn new(pin_number: u8, debounce_delay: Duration) -> Result<Self> {
let gpio = rppal::gpio::Gpio::new().context("Failed to initialize GPIO")?;
let pin = gpio
.get(pin_number)
.context(format!("Failed to get GPIO pin {}", pin_number))?
.into_input_pullup();
Ok(Self {
pin,
debounce_delay,
})
}
}
impl Monitor for GpioMonitor {
fn monitor(&mut self, mut callback: Box<dyn FnMut(StatusEvent) + Send>) -> Result<()> {
self.pin
.set_async_interrupt(rppal::gpio::Trigger::Both, Some(self.debounce_delay), move |event| {
match event.trigger {
rppal::gpio::Trigger::RisingEdge => callback(StatusEvent::Closed),
rppal::gpio::Trigger::FallingEdge => callback(StatusEvent::Open),
_ => (), // Ignore other triggers
}
})?;
Ok(())
}
fn get_current_state(&self) -> StatusEvent {
match self.pin.read() {
rppal::gpio::Level::Low => StatusEvent::Open,
rppal::gpio::Level::High => StatusEvent::Closed,
}
}
}

47
pi/src/logging.rs Normal file
View file

@ -0,0 +1,47 @@
use std::fs;
use anyhow::Result;
use tracing_appender::rolling::RollingFileAppender;
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt};
use crate::config::LoggingConfig;
pub fn init(config: &LoggingConfig) -> Result<()> {
tracing::info!("creating logs directory");
let log_dir = std::path::Path::new(&config.file_path).parent().unwrap_or_else(|| std::path::Path::new("logs"));
fs::create_dir_all(log_dir)?;
tracing::info!("initializing logging");
let file_appender = RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::NEVER)
.filename_prefix("noisebell")
.filename_suffix("log")
.build(log_dir)?;
let (non_blocking, _guard) = tracing_appender::non_blocking::NonBlockingBuilder::default()
.buffered_lines_limit(config.max_buffered_lines)
.finish(file_appender);
// Parse log level from config
let level_filter = match config.level.to_lowercase().as_str() {
"trace" => LevelFilter::TRACE,
"debug" => LevelFilter::DEBUG,
"info" => LevelFilter::INFO,
"warn" => LevelFilter::WARN,
"error" => LevelFilter::ERROR,
_ => LevelFilter::INFO,
};
// Only show our logs and hide hyper logs
let filter = tracing_subscriber::filter::Targets::new()
.with_target("noisebell", level_filter)
.with_target("hyper", LevelFilter::WARN)
.with_target("hyper_util", LevelFilter::WARN);
tracing_subscriber::registry()
.with(filter)
.with(fmt::Layer::default().with_writer(std::io::stdout))
.with(fmt::Layer::default().with_writer(non_blocking))
.init();
Ok(())
}

98
pi/src/main.rs Normal file
View file

@ -0,0 +1,98 @@
mod logging;
mod monitor;
mod gpio_monitor;
mod web_monitor;
mod endpoint_notifier;
mod config;
use std::{fmt, sync::Arc};
use tokio::sync::RwLock;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tracing::{error, info};
// Shared state types
pub type SharedMonitor = Arc<RwLock<Box<dyn monitor::Monitor>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StatusEvent {
Open,
Closed,
}
impl fmt::Display for StatusEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StatusEvent::Open => write!(f, "open"),
StatusEvent::Closed => write!(f, "closed"),
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Load and validate configuration
let config = config::Config::from_env()?;
config.validate()?;
info!("Configuration loaded successfully");
info!("Monitor type: {}", config.monitor.monitor_type);
if config.web_monitor.enabled {
info!("Web monitor: port {}", config.web_monitor.port);
}
// Initialize logging with config
logging::init(&config.logging)?;
// Load endpoint configuration
info!("Using endpoint URL: {}", config.endpoint.url);
let endpoint_config = endpoint_notifier::EndpointConfig {
url: config.endpoint.url.clone(),
api_key: config.endpoint.api_key.clone(),
timeout_secs: config.endpoint.timeout_secs,
retry_attempts: config.endpoint.retry_attempts,
};
let notifier = Arc::new(endpoint_notifier::EndpointNotifier::new(endpoint_config));
info!("initializing {} monitor", config.monitor.monitor_type);
let monitor = monitor::create_monitor(
&config.monitor.monitor_type,
config.gpio.pin,
config.get_debounce_delay(),
if config.web_monitor.enabled { Some(config.web_monitor.port) } else { None },
)?;
let shared_monitor: SharedMonitor = Arc::new(RwLock::new(monitor));
let monitor_for_task = shared_monitor.clone();
let callback = {
let notifier = notifier.clone();
Box::new(move |event: StatusEvent| {
let notifier = notifier.clone();
tokio::spawn(async move {
if let Err(e) = notifier.notify_endpoint(event).await {
error!("Failed to notify endpoint: {}", e);
}
});
})
};
let monitor_handle = tokio::spawn(async move {
if let Err(e) = monitor_for_task.write().await.monitor(callback) {
error!("Monitor error: {}", e);
}
});
info!("Monitor started with endpoint notifications.");
tokio::select! {
_ = monitor_handle => {
info!("Monitor task completed");
}
}
info!("Shutting down noisebell...");
Ok(())
}

19
pi/src/monitor.rs Normal file
View file

@ -0,0 +1,19 @@
use std::time::Duration;
use anyhow::Result;
use crate::StatusEvent;
pub trait Monitor: Send + Sync {
fn monitor(&mut self, callback: Box<dyn FnMut(StatusEvent) + Send>) -> Result<()>;
fn get_current_state(&self) -> StatusEvent;
}
pub fn create_monitor(monitor_type: &str, pin_number: u8, debounce_delay: Duration, web_port: Option<u16>) -> Result<Box<dyn Monitor>> {
match monitor_type {
"gpio" => Ok(Box::new(crate::gpio_monitor::GpioMonitor::new(pin_number, debounce_delay)?)),
"web" => {
let port = web_port.ok_or_else(|| anyhow::anyhow!("Web monitor requires a port number"))?;
Ok(Box::new(crate::web_monitor::WebMonitor::new(port)?))
},
_ => Err(anyhow::anyhow!("Unknown monitor type: {}", monitor_type)),
}
}

176
pi/src/web_monitor.rs Normal file
View file

@ -0,0 +1,176 @@
use std::sync::Arc;
use anyhow::Result;
use axum::{
extract::{ws::{Message, WebSocket, WebSocketUpgrade}, State},
response::{Html, IntoResponse},
routing::{get},
Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, Mutex};
use tracing::{info, error};
use futures_util::{sink::SinkExt, stream::StreamExt};
use tower_http::services::ServeDir;
use crate::{StatusEvent, monitor::Monitor};
#[derive(Clone)]
pub struct WebMonitor {
port: u16,
current_state: Arc<RwLock<StatusEvent>>,
callback: Arc<Mutex<Option<Box<dyn FnMut(StatusEvent) + Send + 'static>>>>,
}
#[derive(Clone)]
struct AppState {
current_state: Arc<RwLock<StatusEvent>>,
callback: Arc<Mutex<Option<Box<dyn FnMut(StatusEvent) + Send + 'static>>>>,
}
#[derive(Serialize, Deserialize)]
struct StateChangeMessage {
event: String,
state: String,
}
impl WebMonitor {
pub fn new(port: u16) -> Result<Self> {
Ok(Self {
port,
current_state: Arc::new(RwLock::new(StatusEvent::Closed)), // Default to closed
callback: Arc::new(Mutex::new(None)),
})
}
async fn serve_html() -> impl IntoResponse {
Html(include_str!("../static/monitor.html"))
}
async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| Self::handle_websocket(socket, state))
}
async fn handle_websocket(socket: WebSocket, state: AppState) {
let (mut sender, mut receiver) = socket.split();
// Send current state immediately
let current_state = *state.current_state.read().await;
let initial_message = StateChangeMessage {
event: "state_update".to_string(),
state: current_state.to_string(),
};
if let Ok(msg) = serde_json::to_string(&initial_message) {
if let Err(e) = sender.send(Message::Text(msg.into())).await {
error!("Failed to send initial state: {}", e);
return;
}
}
// Handle incoming messages from client
let state_for_receiver = state.clone();
while let Some(msg) = receiver.next().await {
match msg {
Ok(Message::Text(text)) => {
let text_str = text.to_string();
if let Ok(state_msg) = serde_json::from_str::<StateChangeMessage>(&text_str) {
if state_msg.event == "state_change" {
let new_state = match state_msg.state.as_str() {
"open" => StatusEvent::Open,
"closed" => StatusEvent::Closed,
_ => continue,
};
// Update current state
{
let mut current = state_for_receiver.current_state.write().await;
*current = new_state;
}
// Trigger callback
{
let mut callback_guard = state_for_receiver.callback.lock().await;
if let Some(ref mut callback) = callback_guard.as_mut() {
callback(new_state);
}
}
info!("Web monitor state changed to: {:?}", new_state);
}
}
}
Ok(Message::Close(_)) => {
info!("WebSocket connection closed");
break;
}
Err(e) => {
error!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
}
async fn start_server(&self) -> Result<()> {
let app_state = AppState {
current_state: self.current_state.clone(),
callback: self.callback.clone(),
};
let app = Router::new()
.route("/", get(Self::serve_html))
.route("/ws", get(Self::websocket_handler))
.nest_service("/media", ServeDir::new("media"))
.with_state(app_state);
let addr = format!("0.0.0.0:{}", self.port);
info!("Starting web monitor server on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
}
impl Monitor for WebMonitor {
fn monitor(&mut self, callback: Box<dyn FnMut(StatusEvent) + Send>) -> Result<()> {
// Store the callback synchronously to ensure it's available immediately
let callback_arc = self.callback.clone();
let rt = tokio::runtime::Handle::current();
tokio::task::block_in_place(|| {
rt.block_on(async {
let mut guard = callback_arc.lock().await;
*guard = Some(callback);
});
});
// Run the web server in a blocking task to avoid runtime conflicts
let server = self.clone();
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
if let Err(e) = rt.block_on(server.start_server()) {
error!("Web monitor server error: {}", e);
}
});
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
fn get_current_state(&self) -> StatusEvent {
// This is a synchronous function, but we need to read async state
// We'll use a blocking operation here similar to how GPIO reads work
let rt = tokio::runtime::Handle::current();
tokio::task::block_in_place(|| {
rt.block_on(async {
*self.current_state.read().await
})
})
}
}

370
pi/static/monitor.html Normal file
View file

@ -0,0 +1,370 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Circuit Monitor</title>
<link rel="icon" type="image/x-icon" href="media/noisebell logo.ico">
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: white;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
color: #333;
}
.container {
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
border: 2px solid #e0e0e0;
text-align: center;
max-width: 500px;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
gap: 15px;
}
.logo {
width: 100px;
height: 100px;
object-fit: contain;
}
h1 {
margin: 0;
font-size: 2.5em;
color: #333;
text-shadow: none;
}
.status-section {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
width: 100%;
}
.status-image {
width: 178px;
height: 500px;
object-fit: contain;
}
/* Toggle Switch Styles */
.switch-container {
display: flex;
align-items: center;
justify-content: center;
margin: 30px 0;
position: relative;
width: 100%;
}
.switch-label {
position: absolute;
font-size: 1.2em;
font-weight: bold;
width: 80px;
text-align: center;
}
.switch-label.open {
color: #2ecc71;
right: calc(50% + 80px);
}
.switch-label.closed {
color: #e74c3c;
left: calc(50% + 80px);
}
.switch {
position: relative;
display: inline-block;
width: 120px;
height: 60px;
z-index: 1;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #2ecc71, #27ae60);
border-radius: 60px;
transition: all 0.1s ease;
box-shadow: 0 4px 15px rgba(46, 204, 113, 0.4);
}
.slider:before {
position: absolute;
content: "";
height: 52px;
width: 52px;
left: 4px;
bottom: 4px;
background: white;
border-radius: 50%;
transition: all 0.1s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
input:checked + .slider {
background: linear-gradient(135deg, #e74c3c, #c0392b);
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4);
}
input:checked + .slider:before {
transform: translateX(60px);
}
.slider:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: all 0.3s ease;
}
input:checked + .slider:after {
background: rgba(255, 255, 255, 0.2);
}
.connection-status {
margin-top: 20px;
padding: 10px;
border-radius: 5px;
font-size: 0.9em;
border: 1px solid;
}
.connection-status.connected {
background: rgba(46, 204, 113, 0.1);
color: #2ecc71;
border-color: #2ecc71;
}
.connection-status.disconnected {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
border-color: #e74c3c;
}
.connection-status.connecting {
background: rgba(241, 196, 15, 0.1);
color: #f39c12;
border-color: #f39c12;
}
@media (max-width: 480px) {
.container {
margin: 20px;
padding: 30px 20px;
}
.header {
flex-direction: column;
gap: 10px;
}
h1 {
font-size: 2em;
}
.status-section {
flex-direction: column;
gap: 15px;
}
.status-image {
width: 60px;
height: 60px;
}
.switch-container {
flex-direction: column;
gap: 15px;
}
.switch-label {
margin: 0;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="/media/noisebell logo.svg" class="logo">
<h1>Circuit Monitor</h1>
</div>
<div class="status-section">
<img src="/media/closed.png" alt="Circuit State" class="status-image" id="statusImage">
</div>
<div class="switch-container">
<div class="switch-label open">OPEN</div>
<label class="switch">
<input type="checkbox" id="circuitSwitch" checked>
<span class="slider"></span>
</label>
<div class="switch-label closed">CLOSED</div>
</div>
<div class="connection-status connecting" id="connectionStatus">
Connecting...
</div>
</div>
<script>
class CircuitMonitor {
constructor() {
this.ws = null;
this.switchElement = document.getElementById('circuitSwitch');
this.statusImage = document.getElementById('statusImage');
this.connectionStatus = document.getElementById('connectionStatus');
this.isUserChange = false;
this.init();
}
init() {
this.setupEventListeners();
this.connect();
}
setupEventListeners() {
this.switchElement.addEventListener('change', (e) => {
if (this.isUserChange) return;
const newState = e.target.checked ? 'closed' : 'open';
this.sendStateChange(newState);
});
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.updateConnectionStatus('connected');
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleMessage(data);
} catch (error) {
console.error('Failed to parse message:', error);
}
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.updateConnectionStatus('disconnected');
// Attempt to reconnect after 3 seconds
setTimeout(() => {
this.updateConnectionStatus('connecting');
this.connect();
}, 3000);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.updateConnectionStatus('disconnected');
};
}
handleMessage(data) {
if (data.event === 'state_update') {
this.updateState(data.state);
}
}
updateState(state) {
this.isUserChange = true;
// Update status image
this.statusImage.src = `/media/${state}.png`;
this.statusImage.alt = `Circuit ${state}`;
console.log(`State updated to: ${state}`);
// Reset flag after a short delay
setTimeout(() => {
this.isUserChange = false;
}, 100);
}
sendStateChange(newState) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const message = {
event: 'state_change',
state: newState
};
this.ws.send(JSON.stringify(message));
console.log(`Sent state change: ${newState}`);
} else {
console.error('WebSocket is not connected');
}
}
updateConnectionStatus(status) {
this.connectionStatus.className = `connection-status ${status}`;
switch (status) {
case 'connected':
this.connectionStatus.textContent = 'Connected';
break;
case 'connecting':
this.connectionStatus.textContent = 'Connecting...';
break;
case 'disconnected':
this.connectionStatus.textContent = 'Disconnected - Reconnecting...';
break;
}
}
}
// Initialize the monitor when the page loads
document.addEventListener('DOMContentLoaded', () => {
new CircuitMonitor();
});
</script>
</body>
</html>