Files
nixos-configs/hosts/manatee/modules/home-assistant/default.nix
T
Alexander Heldt 924d06865a manatee: Reload Plejd when its BLE link goes stale
pyplejd keeps using a dead BLE client ("Service Discovery has not been
performed yet") instead of reconnecting, which left the Plejd lights
unreachable for two days. Fire system log events and let an automation
reload the integration when that warning shows up.

Also add systemd to bt-reset's PATH so its `systemctl` calls work.
2026-11-02 07:22:57 +00:00

339 lines
9.0 KiB
Nix

{
pkgs,
lib,
config,
...
}:
let
nginxEnabled = config.mod.nginx.enable;
cfg = config.mod.home-assistant;
configFile = pkgs.writeText "ha-configuration.yaml" ''
# Loads default set of integrations. Do not remove.
default_config:
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
automation: !include automations.yaml
automation nix:
# pyplejd keeps using a dead BLE client forever ("Service Discovery has not
# been performed yet") instead of reconnecting; reloading the integration fixes it
- alias: Reload Plejd when its BLE link goes stale
mode: single
triggers:
- trigger: event
event_type: system_log_event
event_data:
level: WARNING
conditions:
- "{{ 'pyplejd' in trigger.event.data.name and 'Service Discovery has not been performed yet' in (trigger.event.data.message | join(' ')) }}"
actions:
- action: homeassistant.reload_config_entry
target:
entity_id: light.takbelysning
# With mode: single, this caps it at one reload per 5 minutes
- delay: "00:05:00"
script: !include scripts.yaml
scene: !include scenes.yaml
recorder:
purge_keep_days: 365
# Turns logged warnings/errors into `system_log_event`s that automations can react to
system_log:
fire_event: true
alert:
fridge_door:
name: Fridge is open
done_message: Fride is closed
entity_id: binary_sensor.kyldorr
state: "on"
repeat: 2
skip_first: true
notifiers:
- mobile_app_pixel_9_pro
kitchen_water_leak:
name: Water leak detected in the kitchen
done_message: Kitchen is dry again
entity_id: binary_sensor.vattenlackage_1_flood
state: "on"
repeat: 5
notifiers:
- mobile_app_pixel_9_pro
data:
ttl: 0
priority: high
# Plays on the alarm stream so it sounds even when the phone is silenced
channel: alarm_stream
${cfg.extraConfig}'';
btResetScript = pkgs.writeShellScript "bt-reset" ''
set -euo pipefail
export PATH="${
lib.makeBinPath [
pkgs.bluez
pkgs.util-linux
pkgs.kmod
pkgs.gnugrep
pkgs.coreutils
pkgs.systemd
]
}"
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
{
options = {
mod.home-assistant = {
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra YAML to append to Home Assistant's configuration.yaml";
};
};
};
config = {
mod.homepage.services = [
{
name = "Home Assistant";
port = 8123;
description = "Home automation";
}
];
hardware.bluetooth.enable = true;
# Give up and enter a `failed` state (visible in `systemctl --failed`) if the
# container restarts more than 5 times in 5 minutes, instead of crash-looping
# forever. The window is deliberately long: a slow crash-loop (~5s per attempt)
# stays under systemd's default 5-starts-per-10s limit and would otherwise
# retry indefinitely.
systemd.services.podman-homeassistant = {
startLimitIntervalSec = 300;
startLimitBurst = 5;
};
virtualisation.oci-containers = {
backend = "podman";
containers.homeassistant = {
image = "ghcr.io/home-assistant/home-assistant:stable";
volumes = [
"/home/alex/.config/home-assistant:/config"
"${configFile}:/config/configuration.yaml:ro"
# 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 = btResetScript;
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 romm puppy todo schema dagbok"
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";
};
};
};
};
}