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
14
Cargo.toml
|
|
@ -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"] }
|
||||
82
README.md
|
|
@ -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)
|
||||
|
Before Width: | Height: | Size: 118 KiB |
2
.gitignore → noisebell-pi/.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
|||
/target
|
||||
noisebell.service
|
||||
/logs
|
||||
.env
|
||||
1189
Cargo.lock → noisebell-pi/Cargo.lock
generated
26
noisebell-pi/Cargo.toml
Normal 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
|
|
@ -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
|
|
@ -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
noisebell-pi/media/closed.png
Normal file
|
After Width: | Height: | Size: 419 KiB |
BIN
noisebell-pi/media/noisebell knifeswitch.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
noisebell-pi/media/noisebell logo.ico
Normal file
|
After Width: | Height: | Size: 50 KiB |
161
noisebell-pi/media/noisebell logo.svg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
noisebell-pi/media/noisebell raspberrypi closeup.jpg
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
noisebell-pi/media/noisebell raspberrypi with porthole.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
noisebell-pi/media/open.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
195
noisebell-pi/src/config.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
95
noisebell-pi/src/endpoint_notifier.rs
Normal 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
noisebell-pi/src/gpio_monitor.rs
Normal 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
noisebell-pi/src/logging.rs
Normal 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
|
|
@ -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
noisebell-pi/src/monitor.rs
Normal 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
noisebell-pi/src/web_monitor.rs
Normal 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
noisebell-pi/static/monitor.html
Normal 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
|
|
@ -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
|
|
@ -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.
|
||||
21
noisebell-status/components.json
Normal 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"
|
||||
}
|
||||
16
noisebell-status/eslint.config.mjs
Normal 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;
|
||||
10
noisebell-status/next.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
useCache: true,
|
||||
cacheComponents: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
38
noisebell-status/package.json
Normal 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
4
noisebell-status/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
5
noisebell-status/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
28
noisebell-status/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
1
noisebell-status/public/file.svg
Normal 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 |
1
noisebell-status/public/globe.svg
Normal 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 |
1
noisebell-status/public/next.svg
Normal 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 |
1
noisebell-status/public/vercel.svg
Normal 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 |
1
noisebell-status/public/window.svg
Normal 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 |
102
noisebell-status/src/app/api/status/route.ts
Normal 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 });
|
||||
}
|
||||
BIN
noisebell-status/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
122
noisebell-status/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
34
noisebell-status/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
noisebell-status/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
noisebell-status/src/components/Status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
noisebell-status/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
6
noisebell-status/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
27
noisebell-status/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
68
src/gpio.rs
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
59
src/main.rs
|
|
@ -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(())
|
||||
}
|
||||