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