{ pkgs, lib, config, ... }: let nginxEnabled = config.mod.nginx.enable; script = pkgs.writeShellScript "bt-reset" '' set -euo pipefail export PATH="${ lib.makeBinPath [ pkgs.bluez pkgs.util-linux pkgs.kmod pkgs.gnugrep pkgs.coreutils ] }" logger -t bt-reset "Starting Bluetooth adapter reset..." # Exit early if the adapter is already present and running if hciconfig hci0 2>/dev/null | grep -q "UP RUNNING"; then logger -t bt-reset "hci0 is already UP RUNNING — nothing to do" exit 0 fi # If hci0 exists but isn't UP, try bringing it up if hciconfig hci0 2>/dev/null; then logger -t bt-reset "hci0 exists but not running — bringing it up" hciconfig hci0 up || true sleep 2 if hciconfig hci0 2>/dev/null | grep -q "UP RUNNING"; then logger -t bt-reset "hci0 is UP now" systemctl restart bluetooth.service logger -t bt-reset "bluetooth.service restarted — done" exit 0 fi fi # Hard reset: reload the btusb kernel module (works for USB adapters) logger -t bt-reset "hci0 missing — reloading btusb module..." modprobe -r btusb 2>/dev/null || true sleep 3 modprobe btusb sleep 3 if hciconfig hci0 2>/dev/null; then hciconfig hci0 up logger -t bt-reset "hci0 restored after module reload" else logger -t bt-reset "ERROR: hci0 not found after module reload" exit 1 fi # Restart the bluetooth systemd service so bluetoothd picks up the adapter systemctl restart bluetooth.service logger -t bt-reset "bluetooth.service restarted — done" ''; in { mod.homepage.services = [{ name = "Home Assistant"; port = 8123; description = "Home automation"; }]; hardware.bluetooth.enable = true; virtualisation.oci-containers = { backend = "podman"; containers.homeassistant = { image = "ghcr.io/home-assistant/home-assistant:stable"; volumes = [ "/home/alex/.config/home-assistant:/config" # Pass in bluetooth "/run/dbus:/run/dbus:ro" ]; environment.TZ = "Europe/Stockholm"; extraOptions = [ "--network=host" # Allows HA to perform low-level network operations (scan/reset adapter) "--cap-add=NET_ADMIN" "--cap-add=NET_RAW" # Pass in Zigbee antenna "--device=/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_9C139EAAD464-if00:/dev/ttyACM0" ]; }; }; services = { blueman.enable = true; nginx = lib.mkIf nginxEnabled { recommendedProxySettings = true; virtualHosts."ha.ppp.pm" = { forceSSL = true; useACMEHost = "ha.ppp.pm"; extraConfig = '' proxy_buffering off; ''; locations."/" = { proxyPass = "http://127.0.0.1:8123"; proxyWebsockets = true; }; }; }; # Trigger reset via udev when hci0 disappears udev.extraRules = '' ACTION=="remove", SUBSYSTEM=="bluetooth", KERNEL=="hci0", \ TAG+="systemd", ENV{SYSTEMD_WANTS}+="bt-reset.service" ''; }; systemd = { services = { # Trigger reset on bluetoothd failure bluetooth = { unitConfig.OnFailure = [ "bt-reset.service" ]; }; bt-reset = { description = "Reset Bluetooth adapter"; after = [ "bluetooth.service" ]; serviceConfig = { Type = "oneshot"; ExecStart = script; Restart = "on-failure"; RestartSec = "10s"; StartLimitIntervalSec = "120"; StartLimitBurst = 3; }; }; }; timers.bt-reset = { description = "Periodically reset Bluetooth adapter"; wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = "5min"; # first run 5 min after boot OnUnitActiveSec = "4h"; # then every 4 hours RandomizedDelaySec = "5min"; }; }; user = { timers = { "update-hetzner-dns" = { unitConfig = { Description = "updates Hetzner DNS records"; }; timerConfig = { Unit = "update-hetzner-dns.service"; OnCalendar = "*-*-* *:00/30:00"; Persistent = true; }; wantedBy = [ "timers.target" ]; }; }; services = { "update-hetzner-dns" = { unitConfig = { Description = "updates Hetzner DNS records"; }; serviceConfig = { Type = "exec"; EnvironmentFile = config.age.secrets.hetzner-dns.path; }; path = [ pkgs.curl pkgs.coreutils pkgs.jq ]; script = '' SUBDOMAINS="ha komga" INTERFACE="enp3s0" CURRENT_IP=$(curl -s --fail --interface "$INTERFACE" ifconfig.me) for SUBDOMAIN in $SUBDOMAINS; do LAST_IP_FILE="/tmp/hetzner-dns-''${SUBDOMAIN}-ip" LAST_IP="" if [[ -f "$LAST_IP_FILE" ]]; then LAST_IP=$(cat "$LAST_IP_FILE") fi if [[ "$CURRENT_IP" == "$LAST_IP" ]]; then echo "$SUBDOMAIN: IP unchanged, NOOP update." else echo "$SUBDOMAIN: Updating IP" JSON_BODY=$(jq -n --arg ip "$CURRENT_IP" '{records: [{value: $ip}]}') curl \ --fail \ -X POST \ -H "Authorization: Bearer $HETZNER_API_TOKEN" \ -H "Content-Type: application/json" \ -d "$JSON_BODY" \ "https://api.hetzner.cloud/v1/zones/ppp.pm/rrsets/''${SUBDOMAIN}/A/actions/set_records" \ && echo $CURRENT_IP > $LAST_IP_FILE fi done ''; }; }; }; }; age = { secrets = { "hetzner-dns" = { file = ../../../../secrets/manatee/hetzner-dns.age; owner = "alex"; group = "users"; }; }; }; }