Update Main to monorepo structure (#5)

* feat: Convert to a webhooks api model!

feat: Update readme with new api docs and images and logo

feat: reoptimize jpgs and add comments to all images for credit

feat: Add database backend implementations
Todo is to update the readme

feat: use memory storage for endpoints

feat: add logging to rest api and remove ctrl override

feat: remove keyboard monitor

delete the discord api from direct reference

* feat: webhook sending with retries and backoff

Also some great readme changes

* feat: add a web based dev mode!

* feat: better error handling for webhook endopoints

* feat: remove verbose logs

* feat: add docs for local dev

* feat: remove complex webhook stuff
config file with endpoints listed instead

* feat: update logo

* feat: set endpoint and remove rest api

* fix: check for negative config numbers

* feat: remove timestamps from webhook
Use Date header instead

* feat: refactor to using one endpoint with env vars

* feat: change logging to be one rolling log
With a max line size of 10k lines

* feat: move config to toml, keep api in env var

* feat: use .env files for managing env vars

* fix: remove log files from dev

* fix: unblock web monitor thread

* feat: merge into a monorepo with noisebridge-status
This commit is contained in:
Jet Pham 2025-08-05 00:33:41 -05:00 committed by GitHub
parent 716153b1b6
commit dff2e96947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 6967 additions and 973 deletions

View file

@ -1,14 +0,0 @@
[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"
serenity = { version = "0.12.4", features = ["standard_framework"] }

View file

@ -1,82 +0,0 @@
# Noisebell
A switch monitoring system that detects circuit state changes via GPIO and sends webhook notifications to configured endpoints.
This is build by Jet Pham to be used at Noisebridge to replace their old discord status bot
## Features
- GPIO circuit monitoring with configurable pin
>TODO: - Webhook notifications with retry mechanism
>TODO: - REST API for managing webhook endpoints
- Daily rotating log files
- Cross-compilation support for Raspberry Pi deployment
> Temporarialy calls the discord bot directly
- Debouncing using a finite state machine
## Requirements
- Rust toolchain
- Raspberry Pi (tested on aarch64)
- For development: Cross-compilation tools (for `cross` command)
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/noisebell.git
cd noisebell
```
2. Build the project:
```bash
cargo build --release
```
## Deployment
The project includes a deployment script for Raspberry Pi. To deploy:
1. Ensure you have cross-compilation tools installed:
```bash
cargo install cross
```
2. Run the deployment script:
```bash
./deploy.sh
```
This will:
- Cross-compile the project for aarch64
- Copy the binary and configuration to your Raspberry Pi
- Set appropriate permissions
## Logging
Logs are stored in the `logs` directory with daily rotation for the past 7 days
## Configuration
The following parameters can be configured in `src/main.rs`:
### GPIO Settings
- `DEFAULT_GPIO_PIN`: The GPIO pin number to monitor (default: 17)
- `DEFAULT_POLL_INTERVAL_MS`: How frequently to check the GPIO pin state in milliseconds (default: 100ms)
- `DEFAULT_DEBOUNCE_DELAY_SECS`: How long the switch must remain in a stable state before triggering a change, in seconds (default: 5s)
### Discord Settings
The following environment variables must be set:
- `DISCORD_TOKEN`: Your Discord bot token
- `DISCORD_CHANNEL_ID`: The ID of the channel where status updates will be posted
### Logging Settings
- `LOG_DIR`: Directory where log files are stored (default: "logs")
- `LOG_PREFIX`: Prefix for log filenames (default: "noisebell")
- `LOG_SUFFIX`: Suffix for log filenames (default: "log")
- `MAX_LOG_FILES`: Maximum number of log files to keep (default: 7)
To modify these settings:
1. Edit the constants in `src/main.rs`
2. Rebuild the project
3. For Discord keys and channel id, ensure the environment variables are set before running the bot (Done for you in deploy.sh)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

View file

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

File diff suppressed because it is too large Load diff

26
noisebell-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
noisebell-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

26
noisebell-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

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

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
noisebell-pi/media/open.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

195
noisebell-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(())
}
}

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,
}
}
}

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
noisebell-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(())
}

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)),
}
}

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
})
})
}
}

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>

44
noisebell-status/.gitignore vendored Normal file
View file

@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/generated/prisma
.env*.local

190
noisebell-status/README.md Normal file
View file

@ -0,0 +1,190 @@
# NoiseBell Status API
A simple status API built with Next.js and Prisma, designed to report whether a noisebridge is open or closed.
## API Endpoints
### GET `/api/status`
Retrieves the current status of the service.
**Response:**
- `200 OK`: Returns the current status
- `200 OK`: Returns `{"status": "closed"}` if no status records exist
**Example Response:**
```json
{
"status": "OPEN",
"createdAt": "2024-01-15T10:30:00.000Z"
}
```
### POST `/api/status`
Updates the current status of the service.
**Headers Required:**
```text
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
```
**Request Body:**
```json
{
"status": "open" | "closed"
}
```
**Response:**
- `201 Created`: Status successfully updated
- `400 Bad Request`: Invalid JSON, missing status field, or invalid status value
- `401 Unauthorized`: Missing or invalid API key
- `500 Internal Server Error`: Server configuration error
**Example Request:**
```bash
curl -X POST http://localhost:3000/api/status \
-H "Authorization: Bearer your-api-key-here" \
-H "Content-Type: application/json" \
-d '{"status": "open"}'
```
**Example Response:**
```json
{
"status": "OPEN",
"createdAt": "2024-01-15T10:30:00.000Z"
}
```
## Environment Variables
### Local Development
Create a `.env.local` file in the root directory with the following variables:
```env
DATABASE_URL="postgresql://username:password@localhost:5432/your_database"
API_KEY="your-secret-api-key-here"
```
### Production with Vercel
If you're deploying to Vercel, you can manage environment variables using Vercel CLI:
1. **Link your project to Vercel:**
```bash
pnpx vercel env link
```
2. **Pull environment variables from Vercel:**
```bash
pnpx vercel env pull .env.local
```
3. **Set environment variables in Vercel dashboard:**
- Go to your project in the Vercel dashboard
- Navigate to Settings → Environment Variables
- Add the required variables:
- `DATABASE_URL`: Your PostgreSQL connection string
- `API_KEY`: Your secret API key
### Required Environment Variables
- `DATABASE_URL`: PostgreSQL connection string
- `API_KEY`: Secret key for API authentication
## Database Schema
The API uses a PostgreSQL database with the following schema:
```sql
-- StatusType enum
CREATE TYPE "StatusType" AS ENUM ('OPEN', 'CLOSED');
-- Status table
CREATE TABLE "statuses" (
"id" SERIAL PRIMARY KEY,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "StatusType" NOT NULL DEFAULT 'OPEN'
);
```
## Development
### Prerequisites
- Node.js 18+
- PostgreSQL database
- pnpm (recommended) or npm
### Installation
1. Clone the repository
2. Install dependencies:
```bash
pnpm install
```
3. Set up your environment variables in `.env.local`
4. Run database migrations:
```bash
pnpm prisma db push
```
5. Start the development server:
```bash
pnpm dev
```
The API will be available at `http://localhost:3000/api/status`
### Database Management
Generate Prisma client:
```bash
pnpm prisma generate
```
View database in Prisma Studio:
```bash
pnpm prisma studio
```
## Error Codes
| Status Code | Description |
|-------------|-------------|
| 200 | Success - Status retrieved |
| 201 | Success - Status created/updated |
| 400 | Bad Request - Invalid input |
| 401 | Unauthorized - Invalid or missing API key |
| 500 | Internal Server Error - Server configuration issue |
## Status Values
The API accepts lowercase values in requests but stores them as uppercase enum values:
- `"open"``"OPEN"`
- `"closed"``"CLOSED"`
## License
This project is open source and available under the MIT License.

View file

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View file

@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
useCache: true,
cacheComponents: true,
},
};
export default nextConfig;

View file

@ -0,0 +1,38 @@
{
"name": "noisebell-status",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/client": "^6.12.0",
"@uploadthing/react": "^7.3.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.526.0",
"next": "15.4.2-canary.18",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "^3.3.1",
"uploadthing": "^7.7.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"eslint": "^9.32.0",
"eslint-config-next": "15.4.4",
"prisma": "^6.12.0",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}

4129
noisebell-status/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- sharp
- unrs-resolver

View file

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View file

@ -0,0 +1,28 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
// output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum StatusType {
OPEN
CLOSED
}
model Status {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
status StatusType @default(OPEN)
@@map("statuses")
}

View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,102 @@
import { unstable_cacheTag as cacheTag } from 'next/cache'
import { PrismaClient } from '@prisma/client'
import { revalidateTag } from 'next/cache'
type StatusResponse = {
status: 'OPEN' | 'CLOSED'
createdAt: string
}
// Create a single Prisma client instance
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
// Cached function that returns a StatusResponse to avoid serialization issues
async function getCachedStatus(): Promise<StatusResponse | null> {
'use cache'
cacheTag('status')
const status = await prisma.status.findFirst({
orderBy: {
createdAt: 'desc'
}
});
if (!status) {
return null;
}
return {
status: status.status,
createdAt: status.createdAt.toISOString()
};
}
export async function GET() {
const cachedResult = await getCachedStatus();
if (!cachedResult) {
return Response.json({ status: 'closed' });
}
return Response.json(cachedResult);
}
export async function POST(request: Request) {
const authHeader = request.headers.get('authorization');
const apiKey = process.env.API_KEY;
if (!apiKey) {
console.error('API_KEY environment variable is not set');
return new Response(JSON.stringify({ error: 'Server configuration error' }), { status: 500 });
}
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'Missing or invalid Authorization header' }), { status: 401 });
}
const providedKey = authHeader.substring(7);
if (providedKey !== apiKey) {
return new Response(JSON.stringify({ error: 'Invalid API key' }), { status: 401 });
}
let body;
try {
body = await request.json();
} catch (error) {
return new Response(JSON.stringify({ error: 'Invalid JSON in request body' }), { status: 400 });
}
const { status } = body;
if (!status) {
return new Response(JSON.stringify({ error: 'Missing status field in request body' }), { status: 400 });
}
if (status !== 'open' && status !== 'closed') {
return new Response(JSON.stringify({ error: 'Invalid status value. Must be "open" or "closed"' }), { status: 400 });
}
const statusEnum = status.toUpperCase() as 'OPEN' | 'CLOSED';
const newStatus = await prisma.status.create({
data: {
status: statusEnum,
},
});
revalidateTag('status');
const response: StatusResponse = {
status: newStatus.status,
createdAt: newStatus.createdAt.toISOString()
};
return Response.json(response, { status: 201 });
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View file

@ -0,0 +1,21 @@
import { Suspense } from 'react'
import { Status } from "@/components/Status"
export const experimental_ppr = true
export default function Home() {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 gap-6">
<Suspense fallback={
<div className="inline-block w-fit min-w-[300px] p-6 bg-gray-50 border-2 border-gray-200 rounded-lg">
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded"></div>
</div>
</div>
}>
<Status />
</Suspense>
</div>
);
}

View file

@ -0,0 +1,71 @@
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
async function getStatus(): Promise<'open' | 'closed'> {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/status`, {
cache: 'no-store'
});
if (!response.ok) {
return 'closed';
}
const data = await response.json();
return data?.status === 'open' ? 'open' : 'closed';
} catch {
return 'closed';
}
}
export async function Status() {
const status = await getStatus();
const statusConfig = {
open: {
title: 'Open',
description: "It's time to start hacking.",
image: 'https://raw.githubusercontent.com/jetpham/noisebell/refs/heads/webhooks/media/open.png',
color: 'text-green-600',
bgColor: 'bg-green-50',
borderColor: 'border-green-200'
},
closed: {
title: 'Closed',
description: "We'll see you again soon.",
image: 'https://raw.githubusercontent.com/jetpham/noisebell/refs/heads/webhooks/media/closed.png',
color: 'text-red-600',
bgColor: 'bg-red-50',
borderColor: 'border-red-200'
}
}
const config = statusConfig[status]
return (
<Card className={`${config.bgColor} ${config.borderColor} border-2 inline-block w-fit min-w-[300px]`}>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex-1">
<CardTitle className={`text-2xl font-bold ${config.color}`}>
{config.title}
</CardTitle>
<CardDescription className="text-base">
{config.description}
</CardDescription>
</div>
<div className="flex-shrink-0 ml-4 flex items-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={config.image}
alt={`A knife switch in the ${config.title} position`}
className="h-full max-h-[60px] w-auto object-contain"
/>
</div>
</CardHeader>
</Card>
)
}

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -1,84 +0,0 @@
use std::env;
use std::time::Instant;
use anyhow::Result;
use serenity::all::{prelude::*, Color, CreateEmbed, CreateMessage};
use serenity::model::id::ChannelId;
use tracing::{info, error};
const COLOR_OPEN: Color = Color::new(0x00FF00); // Green for open
const COLOR_CLOSED: Color = Color::new(0xFF0000); // Red for closed
#[derive(Debug)]
pub enum SpaceEvent {
Open,
Closed,
Initializing,
}
pub struct DiscordClient {
client: Client,
}
impl DiscordClient {
pub async fn new() -> Result<Self> {
let token = env::var("DISCORD_TOKEN").expect("Expected DISCORD_TOKEN in environment");
if let Err(e) = serenity::utils::token::validate(&token) {
return Err(anyhow::anyhow!("Invalid Discord token format: {}", e));
}
let intents = GatewayIntents::GUILD_MESSAGES;
let client = Client::builder(&token, intents)
.await
.expect("Error creating Discord client");
Ok(Self { client })
}
pub async fn handle_event(&self, event: SpaceEvent) -> Result<()> {
let start = Instant::now();
info!("Handling Discord event: {:?}", event);
send_discord_message(&self.client, &event).await?;
let duration = start.elapsed();
info!("Discord event handled successfully in {:?}", duration);
Ok(())
}
}
async fn send_discord_message(client: &Client, event: &SpaceEvent) -> Result<()> {
let (title, description, color, thumbnail) = match event {
SpaceEvent::Open => (
"Noisebridge is Open!",
"It's time to start hacking.",
COLOR_OPEN,
"https://www.noisebridge.net/images/7/7f/Open.png"
),
SpaceEvent::Closed => (
"Noisebridge is Closed!",
"We'll see you again soon.",
COLOR_CLOSED,
"https://www.noisebridge.net/images/c/c9/Closed.png"
),
SpaceEvent::Initializing => return Ok(()), // Don't send message for initialization
};
let channel_id = env::var("DISCORD_CHANNEL_ID")
.expect("Expected DISCORD_CHANNEL_ID in environment")
.parse::<u64>()?;
let embed = CreateEmbed::new()
.title(title)
.description(description)
.color(color)
.thumbnail(thumbnail);
if let Err(why) = ChannelId::new(channel_id).send_message(&client.http, CreateMessage::default().add_embed(embed)).await {
error!("Error sending Discord message: {:?}", why);
return Err(anyhow::anyhow!("Failed to send Discord message: {}", why));
}
Ok(())
}

View file

@ -1,68 +0,0 @@
use std::time::Duration;
use std::fmt;
use serde::{Serialize, Deserialize};
use anyhow::{Result, Context};
use rppal::gpio::{Gpio, InputPin, Level, Trigger};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CircuitEvent {
Open,
Closed,
}
impl fmt::Display for CircuitEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CircuitEvent::Open => write!(f, "open"),
CircuitEvent::Closed => write!(f, "closed"),
}
}
}
pub struct GpioMonitor {
pin: InputPin,
debounce_delay: Duration,
}
impl GpioMonitor {
pub fn new(pin_number: u8, debounce_delay: Duration) -> Result<Self> {
let 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,
})
}
pub fn monitor<F>(&mut self, mut callback: F) -> Result<()>
where
F: FnMut(CircuitEvent) + Send + 'static,
{
self.pin.set_async_interrupt(
Trigger::Both,
Some(self.debounce_delay),
move |event| {
match event.trigger {
Trigger::RisingEdge => callback(CircuitEvent::Closed),
Trigger::FallingEdge => callback(CircuitEvent::Open),
_ => (), // Ignore other triggers
}
},
)?;
Ok(())
}
#[allow(dead_code)]
pub fn get_current_state(&self) -> CircuitEvent {
match self.pin.read() {
Level::Low => CircuitEvent::Open,
Level::High => CircuitEvent::Closed,
}
}
}

View file

@ -1,39 +0,0 @@
use std::fs;
use anyhow::Result;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt};
const LOG_DIR: &str = "logs";
const LOG_PREFIX: &str = "noisebell";
const LOG_SUFFIX: &str = "log";
const MAX_LOG_FILES: usize = 7;
pub fn init() -> Result<()> {
tracing::info!("creating logs directory");
fs::create_dir_all(LOG_DIR)?;
tracing::info!("initializing logging");
let file_appender = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix(LOG_PREFIX)
.filename_suffix(LOG_SUFFIX)
.max_log_files(MAX_LOG_FILES)
.build(LOG_DIR)?;
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
// Only show our logs and hide hyper logs
let filter = tracing_subscriber::filter::Targets::new()
.with_target("noisebell", LevelFilter::INFO)
.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(())
}

View file

@ -1,59 +0,0 @@
mod gpio;
mod discord;
mod logging;
use std::time::Duration;
use std::sync::Arc;
use anyhow::Result;
use tracing::{error, info};
const DEFAULT_GPIO_PIN: u8 = 17;
const DEFAULT_DEBOUNCE_DELAY_SECS: u64 = 5;
#[tokio::main]
async fn main() -> Result<()> {
logging::init()?;
info!("initializing Discord client");
let discord_client = discord::DiscordClient::new().await?;
let discord_client = Arc::new(discord_client);
discord_client.handle_event(discord::SpaceEvent::Initializing).await?;
info!("initializing gpio monitor");
let mut gpio_monitor = gpio::GpioMonitor::new(
DEFAULT_GPIO_PIN,
Duration::from_secs(DEFAULT_DEBOUNCE_DELAY_SECS)
)?;
// Get a handle to the current runtime
let runtime = tokio::runtime::Handle::current();
// Set up the callback for state changes
let callback = move |event: gpio::CircuitEvent| {
info!("Circuit state changed to: {:?}", event);
let discord_client = discord_client.clone();
runtime.spawn(async move {
let space_event = match event {
gpio::CircuitEvent::Open => discord::SpaceEvent::Open,
gpio::CircuitEvent::Closed => discord::SpaceEvent::Closed,
};
if let Err(e) = discord_client.handle_event(space_event).await {
error!("Failed to send Discord message: {}", e);
}
});
};
if let Err(e) = gpio_monitor.monitor(callback) {
error!("GPIO monitoring error: {}", e);
return Err(anyhow::anyhow!("GPIO monitoring failed"));
}
info!("GPIO monitoring started. Press Ctrl+C to exit.");
tokio::signal::ctrl_c().await?;
info!("Shutting down...");
Ok(())
}