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.
339 lines
9.0 KiB
Nix
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";
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|