feat: remove vercel web frontend part and rename to pi
2
pi/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
4
pi/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
noisebell.service
|
||||
/logs
|
||||
.env
|
||||
2375
pi/Cargo.lock
generated
Normal file
26
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
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
|
||||
60
pi/deploy.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
echo "Building for Raspberry Pi..."
|
||||
cross build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
# Check if Discord credentials are already set
|
||||
if [ -z "$DISCORD_TOKEN" ]; then
|
||||
echo "Please enter your Discord bot token:"
|
||||
read -s DISCORD_TOKEN
|
||||
fi
|
||||
|
||||
if [ -z "$DISCORD_CHANNEL_ID" ]; then
|
||||
echo "Please enter your Discord channel ID:"
|
||||
read -s DISCORD_CHANNEL_ID
|
||||
fi
|
||||
|
||||
# Create service file with credentials
|
||||
cat > noisebell.service << EOL
|
||||
[Unit]
|
||||
Description=Noisebell Discord Notification Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=noisebridge
|
||||
WorkingDirectory=/home/noisebridge
|
||||
Environment=DISCORD_TOKEN=${DISCORD_TOKEN}
|
||||
Environment=DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID}
|
||||
ExecStart=/home/noisebridge/noisebell
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOL
|
||||
|
||||
echo "Copying to Raspberry Pi..."
|
||||
# Debug remote directory status
|
||||
ssh noisebridge@noisebell.local "pwd && ls -la ~/ && echo 'Directory permissions:' && stat -c '%A %a %n' ~/"
|
||||
# Remove existing files
|
||||
ssh noisebridge@noisebell.local "rm -f /home/noisebridge/noisebell /home/noisebridge/noisebell.service"
|
||||
# Copy files with absolute paths
|
||||
scp -v target/aarch64-unknown-linux-gnu/release/noisebell noisebridge@noisebell.local:/home/noisebridge/noisebell
|
||||
scp -v noisebell.service noisebridge@noisebell.local:/home/noisebridge/noisebell.service
|
||||
|
||||
echo "Setting up service..."
|
||||
# Deploy service
|
||||
ssh noisebridge@noisebell.local "sudo cp /home/noisebridge/noisebell.service /etc/systemd/system/ && \
|
||||
sudo systemctl daemon-reload && \
|
||||
sudo systemctl enable noisebell && \
|
||||
sudo systemctl restart noisebell"
|
||||
|
||||
# Clean up local service file
|
||||
rm noisebell.service
|
||||
|
||||
echo "Deployment complete!"
|
||||
echo "You can check the service status with: ssh noisebridge@noisebell.local 'sudo systemctl status noisebell'"
|
||||
26
pi/env.example
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Environment variables for noisebell
|
||||
# Copy this file to .env and modify as needed
|
||||
|
||||
# GPIO Configuration
|
||||
NOISEBELL_GPIO_PIN=17
|
||||
NOISEBELL_GPIO_DEBOUNCE_DELAY_SECS=5
|
||||
|
||||
# Web Monitor Configuration
|
||||
NOISEBELL_WEB_MONITOR_PORT=8080
|
||||
NOISEBELL_WEB_MONITOR_ENABLED=true
|
||||
|
||||
# Logging Configuration
|
||||
NOISEBELL_LOGGING_LEVEL=info
|
||||
NOISEBELL_LOGGING_FILE_PATH=logs/noisebell.log
|
||||
NOISEBELL_LOGGING_MAX_BUFFERED_LINES=10000
|
||||
|
||||
# Monitor Configuration
|
||||
NOISEBELL_MONITOR_TYPE=web
|
||||
|
||||
# Endpoint Configuration
|
||||
NOISEBELL_ENDPOINT_URL=https://noisebell.jetpham.com/api/status
|
||||
NOISEBELL_ENDPOINT_TIMEOUT_SECS=30
|
||||
NOISEBELL_ENDPOINT_RETRY_ATTEMPTS=3
|
||||
|
||||
# API key for endpoint notifications (optional)
|
||||
ENDPOINT_API_KEY=your_api_key_here
|
||||
BIN
pi/media/closed.png
Normal file
|
After Width: | Height: | Size: 419 KiB |
BIN
pi/media/noisebell knifeswitch.jpg
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
pi/media/noisebell logo.ico
Normal file
|
After Width: | Height: | Size: 50 KiB |
161
pi/media/noisebell logo.svg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
pi/media/noisebell raspberrypi closeup.jpg
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
pi/media/noisebell raspberrypi with porthole.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
pi/media/open.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
195
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
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
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
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
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
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
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
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>
|
||||