feat: expose configurations, add retry, make stable

This commit is contained in:
Jet Pham 2026-03-09 17:11:10 -07:00
parent c6e726c430
commit 50ec63a474
No known key found for this signature in database
11 changed files with 494 additions and 221 deletions

70
pi/README.md Normal file
View file

@ -0,0 +1,70 @@
# noisebell
Monitors a GPIO pin on a Raspberry Pi to detect door open/close events. State changes get POSTed to a webhook endpoint. Current state is available over HTTP.
Runs on NixOS with Tailscale for networking and agenix for secrets.
## Setup
### 1. Hardware config
Replace `hardware-configuration.nix` with the output of `nixos-generate-config --show-hardware-config` on your Pi (or use an appropriate hardware module like `sd-card/sd-image-aarch64.nix`).
### 2. SSH key
Add your SSH public key to `configuration.nix`:
```nix
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA..."
];
```
### 3. Secrets
Get your Pi's SSH host public key and put it in `secrets/secrets.nix`:
```sh
ssh-keyscan <pi-ip> | grep ed25519
```
Then create the encrypted secret files:
```sh
cd secrets
agenix -e endpoint-url.age # paste webhook URL
agenix -e tailscale-auth-key.age # paste Tailscale auth key
```
### 4. Deploy
```sh
nix build .#nixosConfigurations.pi.config.system.build.toplevel
nixos-rebuild switch --flake .#pi --target-host root@noisebell
```
## Configuration
Options under `services.noisebell` in `flake.nix`:
| Option | Default | Description |
|---|---|---|
| `gpioPin` | 17 | GPIO pin to monitor |
| `debounceSecs` | 5 | Debounce delay |
| `port` | 8080 | HTTP status port |
| `retryAttempts` | 3 | Webhook retry count |
| `retryBaseDelaySecs` | 1 | Base delay for exponential backoff |
| `httpTimeoutSecs` | 10 | Timeout for outbound webhook requests |
| `bindAddress` | `0.0.0.0` | Address to bind the HTTP server to |
| `activeLow` | `true` | Whether low GPIO level means open (depends on wiring) |
| `restartDelaySecs` | 5 | Seconds before systemd restarts on failure |
## API
`GET /` — current door state:
```json
{"status": "open", "timestamp": 1710000000}
```
State changes (and initial state on startup) are POSTed to the configured endpoint in the same format.

View file

@ -5,35 +5,30 @@
networking.hostName = "noisebell";
# Enable the noisebell service
# Decrypted at runtime by agenix
age.secrets.endpoint-url.file = ./secrets/endpoint-url.age;
age.secrets.tailscale-auth-key.file = ./secrets/tailscale-auth-key.age;
services.noisebell = {
enable = true;
endpointUrl = "https://example.com/webhook"; # TODO: set your endpoint
port = 80;
endpointUrlFile = config.age.secrets.endpoint-url.path;
};
# 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 = {
services.tailscale = {
enable = true;
virtualHosts.":80".extraConfig = ''
reverse_proxy localhost:${toString config.services.noisebell.port}
'';
authKeyFile = config.age.secrets.tailscale-auth-key.path;
};
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
];
}

178
pi/flake.lock generated Normal file
View file

@ -0,0 +1,178 @@
{
"nodes": {
"agenix": {
"inputs": {
"darwin": "darwin",
"home-manager": "home-manager",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1770165109,
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
"owner": "ryantm",
"repo": "agenix",
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
"type": "github"
},
"original": {
"owner": "ryantm",
"repo": "agenix",
"type": "github"
}
},
"crane": {
"locked": {
"lastModified": 1772560058,
"narHash": "sha256-NuVKdMBJldwUXgghYpzIWJdfeB7ccsu1CC7B+NfSoZ8=",
"owner": "ipetkov",
"repo": "crane",
"rev": "db590d9286ed5ce22017541e36132eab4e8b3045",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"darwin": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1744478979,
"narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=",
"owner": "lnl7",
"repo": "nix-darwin",
"rev": "43975d782b418ebf4969e9ccba82466728c2851b",
"type": "github"
},
"original": {
"owner": "lnl7",
"ref": "master",
"repo": "nix-darwin",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1745494811,
"narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1772963539,
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"noisebell": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay"
},
"locked": {
"path": "./pi-service",
"type": "path"
},
"original": {
"path": "./pi-service",
"type": "path"
},
"parent": []
},
"root": {
"inputs": {
"agenix": "agenix",
"nixpkgs": "nixpkgs",
"noisebell": "noisebell"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"noisebell",
"nixpkgs"
]
},
"locked": {
"lastModified": 1773025773,
"narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -4,9 +4,13 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
noisebell.url = "path:./pi-service";
agenix = {
url = "github:ryantm/agenix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, noisebell }:
outputs = { self, nixpkgs, noisebell, agenix }:
let
nixosModule = { config, lib, pkgs, ... }:
let
@ -17,13 +21,13 @@
enable = lib.mkEnableOption "noisebell GPIO door monitor";
gpioPin = lib.mkOption {
type = lib.types.int;
type = lib.types.ints.unsigned;
default = 17;
description = "GPIO pin number to monitor.";
};
debounceSecs = lib.mkOption {
type = lib.types.int;
type = lib.types.ints.positive;
default = 5;
description = "Debounce delay in seconds.";
};
@ -34,33 +38,104 @@
description = "HTTP port for the status endpoint.";
};
endpointUrl = lib.mkOption {
type = lib.types.str;
description = "URL to POST state changes to.";
endpointUrlFile = lib.mkOption {
type = lib.types.path;
description = "Path to a file containing the endpoint URL (e.g. an agenix secret).";
};
retryAttempts = lib.mkOption {
type = lib.types.ints.unsigned;
default = 3;
description = "Number of retries after a failed webhook POST.";
};
retryBaseDelaySecs = lib.mkOption {
type = lib.types.ints.positive;
default = 1;
description = "Base delay in seconds for exponential backoff between retries.";
};
httpTimeoutSecs = lib.mkOption {
type = lib.types.ints.positive;
default = 10;
description = "Timeout in seconds for outbound HTTP requests to the webhook endpoint.";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "Address to bind the HTTP server to.";
};
activeLow = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether a low GPIO level means open. Set to false if your sensor wiring is inverted.";
};
restartDelaySecs = lib.mkOption {
type = lib.types.ints.positive;
default = 5;
description = "Seconds to wait before systemd restarts the service on failure.";
};
};
config = lib.mkIf cfg.enable {
systemd.services.noisebell = {
users.users.noisebell = {
isSystemUser = true;
group = "noisebell";
extraGroups = [ "gpio" ];
};
users.groups.noisebell = {};
users.groups.gpio = {};
services.udev.extraRules = ''
KERNEL=="gpiomem", GROUP="gpio", MODE="0660"
KERNEL=="gpiochip[0-9]*", GROUP="gpio", MODE="0660"
'';
systemd.services.noisebell = let
bin = "${noisebell.packages.aarch64-linux.default}/bin/noisebell";
in {
description = "Noisebell GPIO door monitor";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
after = [ "network-online.target" "tailscaled.service" ];
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;
NOISEBELL_RETRY_ATTEMPTS = toString cfg.retryAttempts;
NOISEBELL_RETRY_BASE_DELAY_SECS = toString cfg.retryBaseDelaySecs;
NOISEBELL_HTTP_TIMEOUT_SECS = toString cfg.httpTimeoutSecs;
NOISEBELL_BIND_ADDRESS = cfg.bindAddress;
NOISEBELL_ACTIVE_LOW = if cfg.activeLow then "true" else "false";
RUST_LOG = "info";
};
script = ''
export NOISEBELL_ENDPOINT_URL="$(cat ${cfg.endpointUrlFile})"
exec ${bin}
'';
serviceConfig = {
ExecStart = "${noisebell.packages.aarch64-linux.default}/bin/noisebell";
Restart = "on-failure";
RestartSec = 5;
DynamicUser = true;
SupplementaryGroups = [ "gpio" ];
RestartSec = cfg.restartDelaySecs;
User = "noisebell";
Group = "noisebell";
AmbientCapabilities = lib.optionals (cfg.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictSUIDSGID = true;
MemoryDenyWriteExecute = true;
DevicePolicy = "closed";
DeviceAllow = [ "char-gpiomem rw" "char-gpiochip rw" ];
};
};
};
@ -72,6 +147,7 @@
nixosConfigurations.pi = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
modules = [
agenix.nixosModules.default
nixosModule
./configuration.nix
./hardware-configuration.nix

View file

@ -1,15 +1,6 @@
{ 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;

1
pi/pi-service/.envrc Normal file
View file

@ -0,0 +1 @@
use flake

161
pi/pi-service/Cargo.lock generated
View file

@ -610,9 +610,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "linux-raw-sys"
@ -626,15 +626,6 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
@ -761,29 +752,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -851,15 +819,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "reqwest"
version = "0.12.28"
@ -990,12 +949,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
@ -1106,16 +1059,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
@ -1130,12 +1073,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -1243,9 +1186,7 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@ -1630,16 +1571,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
"windows-targets",
]
[[package]]
@ -1657,31 +1589,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@ -1690,96 +1605,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View file

@ -10,6 +10,6 @@ reqwest = { version = "0.12", features = ["json"] }
rppal = "0.22"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "signal"] }
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -56,6 +56,10 @@
{
packages.aarch64-linux.default = noisebell;
packages.aarch64-linux.noisebell = noisebell;
devShells.${system}.default = craneLib.devShell {
packages = [ pkgs.rust-analyzer ];
};
};
in
forSystem "x86_64-linux";

View file

@ -1,16 +1,29 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
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};
use tracing::{error, info, warn};
struct AppState {
is_open: AtomicBool,
last_changed: AtomicU64,
}
#[derive(Serialize)]
struct StatusResponse {
status: &'static str,
timestamp: u64,
}
fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn status_str(is_open: bool) -> &'static str {
@ -21,15 +34,18 @@ fn status_str(is_open: bool) -> &'static str {
}
}
async fn get_status(State(is_open): State<Arc<AtomicBool>>) -> Json<StatusResponse> {
async fn get_status(State(state): State<Arc<AppState>>) -> Json<StatusResponse> {
Json(StatusResponse {
status: status_str(is_open.load(Ordering::Relaxed)),
status: status_str(state.is_open.load(Ordering::Relaxed)),
timestamp: state.last_changed.load(Ordering::Relaxed),
})
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let gpio_pin: u8 = std::env::var("NOISEBELL_GPIO_PIN")
.unwrap_or_else(|_| "17".into())
@ -49,68 +65,136 @@ async fn main() -> Result<()> {
let endpoint_url =
std::env::var("NOISEBELL_ENDPOINT_URL").context("NOISEBELL_ENDPOINT_URL is required")?;
let retry_attempts: u32 = std::env::var("NOISEBELL_RETRY_ATTEMPTS")
.unwrap_or_else(|_| "3".into())
.parse()
.context("NOISEBELL_RETRY_ATTEMPTS must be a valid u32")?;
let retry_base_delay_secs: u64 = std::env::var("NOISEBELL_RETRY_BASE_DELAY_SECS")
.unwrap_or_else(|_| "1".into())
.parse()
.context("NOISEBELL_RETRY_BASE_DELAY_SECS must be a valid u64")?;
let http_timeout_secs: u64 = std::env::var("NOISEBELL_HTTP_TIMEOUT_SECS")
.unwrap_or_else(|_| "10".into())
.parse()
.context("NOISEBELL_HTTP_TIMEOUT_SECS must be a valid u64")?;
let bind_address = std::env::var("NOISEBELL_BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".into());
let active_low: bool = std::env::var("NOISEBELL_ACTIVE_LOW")
.unwrap_or_else(|_| "true".into())
.parse()
.context("NOISEBELL_ACTIVE_LOW must be true or false")?;
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();
.context(format!("failed to get GPIO pin {gpio_pin}"))?;
let pin = if active_low {
pin.into_input_pullup()
} else {
pin.into_input_pulldown()
};
let is_open = Arc::new(AtomicBool::new(pin.read() == Level::Low));
let open_level = if active_low { Level::Low } else { Level::High };
let initial_open = pin.read() == open_level;
let state = Arc::new(AppState {
is_open: AtomicBool::new(initial_open),
last_changed: AtomicU64::new(unix_timestamp()),
});
info!(initial_status = status_str(is_open.load(Ordering::Relaxed)), "GPIO initialized");
info!(initial_status = status_str(initial_open), "GPIO initialized");
// Channel to bridge sync GPIO callback -> async notification task
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<bool>();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, u64)>();
// Set up async interrupt for state changes
let state_for_interrupt = is_open.clone();
pin.set_async_interrupt(
// Sync initial state with the cache on startup
let _ = tx.send((initial_open, unix_timestamp()));
let state_for_interrupt = state.clone();
// pin must live for the entire program — rppal runs interrupts on a background
// thread tied to the InputPin. If pin drops, the interrupt thread is joined and
// monitoring stops. We move it into a binding that lives until main() returns.
let _pin = pin;
_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,
Trigger::FallingEdge => active_low,
Trigger::RisingEdge => !active_low,
_ => return,
};
let was_open = state_for_interrupt.swap(new_open, Ordering::Relaxed);
let was_open = state_for_interrupt.is_open.swap(new_open, Ordering::Relaxed);
if was_open != new_open {
let _ = tx.send(new_open);
let timestamp = unix_timestamp();
state_for_interrupt.last_changed.store(timestamp, Ordering::Relaxed);
let _ = tx.send((new_open, timestamp));
}
},
)
.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");
let notify_handle = tokio::spawn(async move {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(http_timeout_secs))
.build()
.expect("failed to build HTTP client");
if let Err(e) = client
.post(&endpoint_url)
.json(&serde_json::json!({ "status": status }))
.send()
.await
{
error!(%e, "failed to notify endpoint");
while let Some((new_open, timestamp)) = rx.recv().await {
let status = status_str(new_open);
info!(status, timestamp, "state changed");
let payload = serde_json::json!({ "status": status, "timestamp": timestamp });
for attempt in 0..=retry_attempts {
let result = client.post(&endpoint_url).json(&payload).send().await;
match result {
Ok(resp) if resp.status().is_success() => break,
_ => {
let err_msg = match &result {
Ok(resp) => format!("HTTP {}", resp.status()),
Err(e) => e.to_string(),
};
if attempt == retry_attempts {
error!(error = %err_msg, "failed to notify endpoint after {} attempts", retry_attempts + 1);
} else {
let delay = Duration::from_secs(
retry_base_delay_secs * 2u64.pow(attempt),
);
warn!(error = %err_msg, attempt = attempt + 1, "notify failed, retrying in {:?}", delay);
tokio::time::sleep(delay).await;
}
}
}
}
}
});
let app = Router::new()
.route("/status", get(get_status))
.with_state(is_open);
.route("/", get(get_status))
.with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
let listener = tokio::net::TcpListener::bind((&*bind_address, port))
.await
.context(format!("failed to bind to port {port}"))?;
.context(format!("failed to bind to {bind_address}:{port}"))?;
info!(port, "listening");
axum::serve(listener, app).await.context("server error")?;
let shutdown = tokio::signal::ctrl_c();
axum::serve(listener, app)
.with_graceful_shutdown(async { shutdown.await.ok(); })
.await
.context("server error")?;
info!("shutting down, draining notification queue");
// Drop the interrupt to stop producing new messages, then wait
// for the notification task to drain remaining messages.
drop(_pin);
let _ = notify_handle.await;
info!("shutdown complete");
Ok(())
}

7
pi/secrets/secrets.nix Normal file
View file

@ -0,0 +1,7 @@
let
pi = "ssh-ed25519 AAAA..."; # Pi's SSH host public key
in
{
"endpoint-url.age".publicKeys = [ pi ];
"tailscale-auth-key.age".publicKeys = [ pi ];
}