feat: rewrite pi to be simple and nix based
4
pi/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
|||
/target
|
||||
noisebell.service
|
||||
/logs
|
||||
.env
|
||||
|
|
@ -1,26 +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"
|
||||
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
|
|
@ -1,180 +0,0 @@
|
|||
# <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
|
||||
39
pi/configuration.nix
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{ config, pkgs, ... }:
|
||||
|
||||
{
|
||||
system.stateVersion = "24.11";
|
||||
|
||||
networking.hostName = "noisebell";
|
||||
|
||||
# Enable the noisebell service
|
||||
services.noisebell = {
|
||||
enable = true;
|
||||
endpointUrl = "https://example.com/webhook"; # TODO: set your endpoint
|
||||
};
|
||||
|
||||
# Basic system config
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
|
||||
# Tailscale
|
||||
services.tailscale.enable = true;
|
||||
|
||||
# Caddy reverse proxy — proxies to the noisebell status endpoint
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
virtualHosts.":80".extraConfig = ''
|
||||
reverse_proxy localhost:${toString config.services.noisebell.port}
|
||||
'';
|
||||
};
|
||||
|
||||
services.openssh.enable = true;
|
||||
|
||||
# Only allow traffic from Tailscale interface
|
||||
networking.firewall = {
|
||||
trustedInterfaces = [ "tailscale0" ];
|
||||
allowedUDPPorts = [ config.services.tailscale.port ];
|
||||
};
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
# TODO: add your SSH public key
|
||||
];
|
||||
}
|
||||
60
pi/deploy.sh
|
|
@ -1,60 +0,0 @@
|
|||
#!/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'"
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# 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
|
||||
81
pi/flake.nix
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
description = "NixOS configuration for noisebell Pi";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
noisebell.url = "path:./pi-service";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, noisebell }:
|
||||
let
|
||||
nixosModule = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.noisebell;
|
||||
in
|
||||
{
|
||||
options.services.noisebell = {
|
||||
enable = lib.mkEnableOption "noisebell GPIO door monitor";
|
||||
|
||||
gpioPin = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 17;
|
||||
description = "GPIO pin number to monitor.";
|
||||
};
|
||||
|
||||
debounceSecs = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 5;
|
||||
description = "Debounce delay in seconds.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8080;
|
||||
description = "HTTP port for the status endpoint.";
|
||||
};
|
||||
|
||||
endpointUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "URL to POST state changes to.";
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
systemd.services.noisebell = {
|
||||
description = "Noisebell GPIO door monitor";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
environment = {
|
||||
NOISEBELL_GPIO_PIN = toString cfg.gpioPin;
|
||||
NOISEBELL_DEBOUNCE_SECS = toString cfg.debounceSecs;
|
||||
NOISEBELL_PORT = toString cfg.port;
|
||||
NOISEBELL_ENDPOINT_URL = cfg.endpointUrl;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${noisebell.packages.aarch64-linux.default}/bin/noisebell";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
DynamicUser = true;
|
||||
SupplementaryGroups = [ "gpio" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
nixosModules.default = nixosModule;
|
||||
|
||||
nixosConfigurations.pi = nixpkgs.lib.nixosSystem {
|
||||
system = "aarch64-linux";
|
||||
modules = [
|
||||
nixosModule
|
||||
./configuration.nix
|
||||
./hardware-configuration.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
22
pi/hardware-configuration.nix
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{ config, lib, pkgs, modulesPath, ... }:
|
||||
|
||||
{
|
||||
# TODO: Replace this file with the output of `nixos-generate-config --show-hardware-config`
|
||||
# on your Raspberry Pi, or use an appropriate hardware module.
|
||||
#
|
||||
# Example for Raspberry Pi 4:
|
||||
#
|
||||
# imports = [ "${modulesPath}/installer/sd-card/sd-image-aarch64.nix" ];
|
||||
#
|
||||
# hardware.enableRedistributableFirmware = true;
|
||||
|
||||
imports = [ ];
|
||||
|
||||
boot.loader.grub.enable = false;
|
||||
boot.loader.generic-extlinux-compatible.enable = true;
|
||||
|
||||
fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/NIXOS_SD";
|
||||
fsType = "ext4";
|
||||
};
|
||||
}
|
||||
|
Before Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 458 KiB |
1501
pi/Cargo.lock → pi/pi-service/Cargo.lock
generated
15
pi/pi-service/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "noisebell"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
rppal = "0.22"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
62
pi/pi-service/flake.nix
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
description = "Noisebell - GPIO door monitor service";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, crane, rust-overlay }:
|
||||
let
|
||||
forSystem = system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
crossPkgs = import nixpkgs {
|
||||
inherit system;
|
||||
crossSystem.config = "aarch64-unknown-linux-gnu";
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
targets = [ "aarch64-unknown-linux-gnu" ];
|
||||
};
|
||||
|
||||
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
|
||||
|
||||
src = craneLib.cleanCargoSource ./.;
|
||||
|
||||
commonArgs = {
|
||||
inherit src;
|
||||
strictDeps = true;
|
||||
doCheck = false;
|
||||
|
||||
CARGO_BUILD_TARGET = "aarch64-unknown-linux-gnu";
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER =
|
||||
"${crossPkgs.stdenv.cc.targetPrefix}cc";
|
||||
|
||||
HOST_CC = "${pkgs.stdenv.cc.nativePrefix}cc";
|
||||
|
||||
depsBuildBuild = [ crossPkgs.stdenv.cc ];
|
||||
};
|
||||
|
||||
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
|
||||
|
||||
noisebell = craneLib.buildPackage (commonArgs // {
|
||||
inherit cargoArtifacts;
|
||||
});
|
||||
in
|
||||
{
|
||||
packages.aarch64-linux.default = noisebell;
|
||||
packages.aarch64-linux.noisebell = noisebell;
|
||||
};
|
||||
in
|
||||
forSystem "x86_64-linux";
|
||||
}
|
||||
116
pi/pi-service/src/main.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{extract::State, routing::get, Json, Router};
|
||||
use rppal::gpio::{Gpio, Level, Trigger};
|
||||
use serde::Serialize;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatusResponse {
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
fn status_str(is_open: bool) -> &'static str {
|
||||
if is_open {
|
||||
"open"
|
||||
} else {
|
||||
"closed"
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_status(State(is_open): State<Arc<AtomicBool>>) -> Json<StatusResponse> {
|
||||
Json(StatusResponse {
|
||||
status: status_str(is_open.load(Ordering::Relaxed)),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let gpio_pin: u8 = std::env::var("NOISEBELL_GPIO_PIN")
|
||||
.unwrap_or_else(|_| "17".into())
|
||||
.parse()
|
||||
.context("NOISEBELL_GPIO_PIN must be a valid u8")?;
|
||||
|
||||
let debounce_secs: u64 = std::env::var("NOISEBELL_DEBOUNCE_SECS")
|
||||
.unwrap_or_else(|_| "5".into())
|
||||
.parse()
|
||||
.context("NOISEBELL_DEBOUNCE_SECS must be a valid u64")?;
|
||||
|
||||
let port: u16 = std::env::var("NOISEBELL_PORT")
|
||||
.unwrap_or_else(|_| "8080".into())
|
||||
.parse()
|
||||
.context("NOISEBELL_PORT must be a valid u16")?;
|
||||
|
||||
let endpoint_url =
|
||||
std::env::var("NOISEBELL_ENDPOINT_URL").context("NOISEBELL_ENDPOINT_URL is required")?;
|
||||
|
||||
info!(gpio_pin, debounce_secs, port, %endpoint_url, "starting noisebell");
|
||||
|
||||
let gpio = Gpio::new().context("failed to initialize GPIO")?;
|
||||
let pin = gpio
|
||||
.get(gpio_pin)
|
||||
.context(format!("failed to get GPIO pin {gpio_pin}"))?
|
||||
.into_input_pullup();
|
||||
|
||||
let is_open = Arc::new(AtomicBool::new(pin.read() == Level::Low));
|
||||
|
||||
info!(initial_status = status_str(is_open.load(Ordering::Relaxed)), "GPIO initialized");
|
||||
|
||||
// Channel to bridge sync GPIO callback -> async notification task
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<bool>();
|
||||
|
||||
// Set up async interrupt for state changes
|
||||
let state_for_interrupt = is_open.clone();
|
||||
pin.set_async_interrupt(
|
||||
Trigger::Both,
|
||||
Some(Duration::from_secs(debounce_secs)),
|
||||
move |event| {
|
||||
let new_open = match event.trigger {
|
||||
Trigger::FallingEdge => true,
|
||||
Trigger::RisingEdge => false,
|
||||
_ => return,
|
||||
};
|
||||
let was_open = state_for_interrupt.swap(new_open, Ordering::Relaxed);
|
||||
if was_open != new_open {
|
||||
let _ = tx.send(new_open);
|
||||
}
|
||||
},
|
||||
)
|
||||
.context("failed to set GPIO interrupt")?;
|
||||
|
||||
// Task that POSTs state changes to the endpoint
|
||||
tokio::spawn(async move {
|
||||
let client = reqwest::Client::new();
|
||||
while let Some(new_open) = rx.recv().await {
|
||||
let status = status_str(new_open);
|
||||
info!(status, "state changed");
|
||||
|
||||
if let Err(e) = client
|
||||
.post(&endpoint_url)
|
||||
.json(&serde_json::json!({ "status": status }))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
error!(%e, "failed to notify endpoint");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/status", get(get_status))
|
||||
.with_state(is_open);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
|
||||
.await
|
||||
.context(format!("failed to bind to port {port}"))?;
|
||||
|
||||
info!(port, "listening");
|
||||
axum::serve(listener, app).await.context("server error")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
195
pi/src/config.rs
|
|
@ -1,195 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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(())
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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)),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
<!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>
|
||||