diff --git a/docs/superpowers/contracts/homeassistant-viomi-2026-09-27.json b/docs/superpowers/contracts/homeassistant-viomi-2026-09-27.json new file mode 100644 index 00000000..239dd9ff --- /dev/null +++ b/docs/superpowers/contracts/homeassistant-viomi-2026-09-27.json @@ -0,0 +1,16 @@ +{ + "title": "Home Assistant for LAN control of the Viomi V3 robot vacuum", + "objective": "Deploy Home Assistant on the PC (Docker, host networking, pinned image) with the hass-xiaomi-miot integration so viomi.vacuum.v13 can be started and docked over the LAN from a FOSS UI, and open tcp/8123 to the LAN and wg0 only through the existing single-source-of-truth firewall script. Success: start and dock work from the Home Assistant Android app on the Pixel 6a, and setup_wireguard_ssh.sh verify passes.", + "acceptance_criteria": [ + "setup_homeassistant.sh setup is idempotent and ends with the HA API answering on 127.0.0.1:8123", + "setup_homeassistant.sh status reports container, API, xiaomi_miot version and the 8123 firewall rule", + "setup_wireguard_ssh.sh allow-homeassistant persists ALLOW_HOMEASSISTANT and renders exactly two 8123 accept rules (LAN subnet, wg0)", + "setup_wireguard_ssh.sh verify passes on the host after the change" + ], + "out_of_scope": [ + "Exposing Home Assistant to the internet", + "Rooting or reflashing the robot", + "Automations, dashboards or other devices" + ], + "verifier": "vmbox run of allow-homeassistant + verify; host setup_wireguard_ssh.sh verify; setup_homeassistant.sh setup/status; on-device start/dock from the HA companion app" +} diff --git a/docs/superpowers/evidence/homeassistant-viomi-2026-09-27.json b/docs/superpowers/evidence/homeassistant-viomi-2026-09-27.json new file mode 100644 index 00000000..454ea4bd --- /dev/null +++ b/docs/superpowers/evidence/homeassistant-viomi-2026-09-27.json @@ -0,0 +1,47 @@ +{ + "intent": "Run Home Assistant on the PC so the Viomi V3 robot vacuum (viomi.vacuum.v13) can be controlled over the LAN from a FOSS UI, including the Home Assistant Android app, with tcp/8123 reachable from the LAN and WireGuard only.", + "scope": [ + "linux_configuration/features/setup_homeassistant.sh (new)", + "linux_configuration/features/setup_wireguard_ssh.sh, lib/wg_sshd.sh, lib/wg_firewall.sh: persisted ALLOW_HOMEASSISTANT flag + allow-homeassistant subcommand", + "Non-goal: exposing Home Assistant to the internet; rooting the robot" + ], + "changes": [ + "setup_homeassistant.sh writes ~/services/homeassistant/docker-compose.yml (host networking, image pinned by digest to 2026.9.4), installs al-one/hass-xiaomi-miot v1.1.5 into config/custom_components, starts the container and waits for the API; `status` self-diagnoses container, API, component version and firewall rule", + "render_nftables_ruleset emits `ip saddr tcp dport 8123 accept` and `iifname wg0 tcp dport 8123 accept` when ALLOW_HOMEASSISTANT=true; allow_homeassistant() persists the flag and applies it like allow_dns" + ], + "verification": [ + { + "command": "vm new hafw; sudo bash ~/lc/features/setup_wireguard_ssh.sh allow-homeassistant; nft list chain inet filter input; setup_wireguard_ssh.sh verify", + "result": "pass", + "evidence": "vmbox guest: both 8123 rules present in live input chain, ALLOW_HOMEASSISTANT=\"true\" persisted, verify: /etc/nftables.conf matches generator, forward chain lets Docker through" + }, + { + "command": "render_nftables_ruleset into tmp; diff vs /etc/nftables.conf; nft -c -f; then host apply + sudo setup_wireguard_ssh.sh verify", + "result": "pass", + "evidence": "host diff was exactly the two 8123 lines; nft -c OK; verify: file matches generator and live chain. Applied via nft add rule (no flush) to avoid restarting dockerd under running containers" + }, + { + "command": "bash linux_configuration/features/setup_homeassistant.sh setup", + "result": "pass", + "evidence": "Installed xiaomi_miot v1.1.5; container started; HA answers on http://127.0.0.1:8123 (HTTP 401)" + }, + { + "command": "HA REST: POST /api/services/vacuum/start then vacuum/return_to_base on vacuum.viomi_v13_9f32_robot_cleaner", + "result": "pass", + "evidence": "state cleaning (miio status 5 Sweeping) then returning -> docked" + }, + { + "command": "Pixel 6a, HA companion minimal 2026.6.5: login to http://192.168.1.43:8123, tap play then dock on the robot card", + "result": "pass", + "evidence": "state cleaning after play; returning -> docked 56 s after dock tap" + } + ], + "risks": [ + "hass-xiaomi-miot issue #2677 (Developer-tools send_command broken since v1.1.0) is still open; vacuum start/dock verified working through entity services", + "Host networking: HA listens on 0.0.0.0:8123; only the nftables input rules keep it LAN/wg0-only" + ], + "rollback": [ + "docker compose -f ~/services/homeassistant/docker-compose.yml down; set ALLOW_HOMEASSISTANT=\"false\" in features/.wireguard_ssh.conf and re-run sudo setup_wireguard_ssh.sh allow-web (regenerates the ruleset)", + "Validate: sudo setup_wireguard_ssh.sh verify passes and no dport 8123 rule remains" + ] +} diff --git a/docs/superpowers/evidence/suite-speedup-2026-08-23.json b/docs/superpowers/evidence/suite-speedup-2026-08-23.json deleted file mode 100644 index 2fb08aa3..00000000 --- a/docs/superpowers/evidence/suite-speedup-2026-08-23.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "intent": "Cut the fixes/lib test suite's runtime from 110s to 37s so the commit/push cycle stops being dominated by it, without weakening a single assertion.", - "scope": [ - "linux_configuration/scripts/single_use/fixes/lib/tests/lib_test_core.sh and the new lib_test_path.sh", - "linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh", - "linux_configuration/scripts/single_use/fixes/lib/thorium_repairs.sh (one timing seam)", - "linux_configuration/scripts/single_use/fixes/lib/tests/thorium_harness.sh" - ], - "changes": [ - "thorium_repairs.sh's hardcoded `sleep 4` is now `sleep ${THORIUM_STARTUP_WAIT:-4}`; the harness sets 0.2s. Three cases paid four real seconds each.", - "_t_hide now builds ONE farm of every executable on PATH and hard-links it per tool set with `cp -al`, instead of walking PATH and creating ~13.5k symlinks for each distinct set", - "The farm cache moved out of $TEST_TMPDIR into a run-scoped dir exported by run_all.sh, because each test file gets a fresh tmpdir and the cache never survived between files", - "Split _t_hide and the farm into lib_test_path.sh; lib_test_core.sh had reached exactly 250 lines" - ], - "verification": [ - { - "command": "time bash linux_configuration/scripts/single_use/fixes/lib/tests/run_all.sh", - "result": "pass", - "evidence": "110.0s at session start -> 98.5s after the sleep seam -> 84.6s after the run-scoped cache -> 36.7s after the hard-linked farm. Exit 0 throughout." - }, - { - "command": "Per-file timing of the slowest files", - "result": "pass", - "evidence": "test_thorium_repairs_deep.sh 17041ms -> 5563ms. A single _t_hide farm build measured 4645ms, with ~16 distinct tool sets per run." - }, - { - "command": "bash run_all.sh | grep -c '^ OK' and '^ FAIL'", - "result": "pass", - "evidence": "645 OK, 0 FAIL. No assertion was deleted, skipped or weakened." - }, - { - "command": "Targeted re-check that the 'not installed' branches still hide", - "result": "pass", - "evidence": "install_pyroveil's missing-dependency cases and fix_aggressive's sqlite3-absent case all still pass; they assert on behaviour only reachable when the tool is genuinely unfindable, so a broken farm would fail them loudly." - }, - { - "command": "shellcheck + wc -l on both split files", - "result": "pass", - "evidence": "lib_test_core.sh 194 lines, lib_test_path.sh 67 lines, both clean with zero suppressions." - } - ], - "risks": [ - "`cp -al` hard-links symlinks; if a future _t_hide caller mutated the farm in place it would corrupt the shared copy. Callers only ever read it and the per-set copy is what gets the deletions.", - "The shared farm assumes PATH does not change mid-run. _t_full_path restores the same original PATH, so the assumption holds for this suite.", - "THORIUM_STARTUP_WAIT shortens a real timing check to 0.2s in tests; the stubbed browser sleeps 30s, so the process is still comfortably alive when kill -0 runs." - ], - "rollback": [ - "git revert the commit; the suite returns to ~110s with identical assertions", - "Re-run run_all.sh and confirm 645 OK / 0 FAIL is unchanged" - ] -} diff --git a/linux_configuration/features/lib/wg_firewall.sh b/linux_configuration/features/lib/wg_firewall.sh index b322249d..e6ce01f2 100755 --- a/linux_configuration/features/lib/wg_firewall.sh +++ b/linux_configuration/features/lib/wg_firewall.sh @@ -28,6 +28,13 @@ render_nftables_ruleset() { # saddr. Link-local only: :67 broadcasts never route in from the internet. dhcp_rule=$'\n\t\tudp sport 68 udp dport 67 accept' fi + # Home Assistant (setup_homeassistant.sh): its UI/API on 8123 for the phone + # app, from the LAN and over WireGuard only -- never the internet. + local ha_rule="" + if [[ ${ALLOW_HOMEASSISTANT:-false} == "true" ]]; then + ha_rule=$'\n\t\tip saddr '"${LAN_SUBNET}"$' tcp dport 8123 accept' + ha_rule+=$'\n\t\tiifname "'"${WG_IFACE}"$'" tcp dport 8123 accept' + fi cat >"$target" </dev/null || echo "(interface not up)" diff --git a/linux_configuration/features/setup_homeassistant.sh b/linux_configuration/features/setup_homeassistant.sh new file mode 100755 index 00000000..f5c1c52a --- /dev/null +++ b/linux_configuration/features/setup_homeassistant.sh @@ -0,0 +1,177 @@ +#!/bin/bash +# +# setup_homeassistant.sh — Home Assistant on this PC, for LAN-local control of +# the Viomi V3 robot vacuum (viomi.vacuum.v13) and anything added later. +# +# What it does (idempotent — safe to re-run): +# - Writes ~/services/homeassistant/docker-compose.yml and runs HA with +# network_mode: host (bridge containers have no egress on this host; see +# setup_searxng.sh for the nftables reason), listening on :8123. +# - Installs the al-one/hass-xiaomi-miot custom integration at a pinned tag +# into config/custom_components/xiaomi_miot (MIoT over LAN; HA core's own +# xiaomi_miio does not know viomi.vacuum.v13). +# - Waits until the HTTP API answers. +# +# Reachability: 8123 is opened to the LAN and wg0 only, by +# sudo setup_wireguard_ssh.sh allow-homeassistant +# (the firewall's single source of truth). `status` checks that rule. +# +# Usage: +# setup_homeassistant.sh [setup] Deploy (default). +# setup_homeassistant.sh status Self-diagnose the deployment. +# setup_homeassistant.sh help Show this help. +# +# Run as your normal user, not root. + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +readonly SCRIPT_DIR +# shellcheck source=../lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +# --- Configuration ---------------------------------------------------------- +readonly HA_PORT="8123" +# home-assistant 2026.9.4 (newest stable on 2026-09-27), pinned by digest so a +# re-run never silently upgrades. To upgrade: change digest + comment, re-run. +readonly HA_IMAGE="ghcr.io/home-assistant/home-assistant@sha256:3e6710a7ab2a61311d9d899b719f6c3657791c63e8f4942cec4ebc42401d6b76" +readonly HA_CONTAINER="homeassistant" +# hass-xiaomi-miot newest stable on 2026-09-27. Issue #2677 (send_command via +# Developer tools broken since v1.1.0) is still open; vacuum start/dock go +# through entity services, not that path. +readonly MIOT_VERSION="v1.1.5" +readonly MIOT_REPO="al-one/hass-xiaomi-miot" +readonly HA_DIR="${HOME}/services/homeassistant" +readonly HA_COMPOSE="${HA_DIR}/docker-compose.yml" +readonly HA_CONFIG="${HA_DIR}/config" +readonly MIOT_DIR="${HA_CONFIG}/custom_components/xiaomi_miot" +readonly MIOT_STAMP="${MIOT_DIR}/.installed_version" + +TMP_DIR="" +cleanup() { + if [[ -n ${TMP_DIR} && -d ${TMP_DIR} ]]; then + rm -rf "${TMP_DIR}" + fi +} +trap cleanup EXIT + +die() { + log_error "$1" + exit 1 +} + +usage() { + grep -E '^#( |$)' "$0" | sed -E 's/^# ?//' + exit "${1:-0}" +} + +preflight() { + [[ ${EUID} -ne 0 ]] || die "Run as your normal user, not root." + install_missing_pacman_packages docker docker-compose curl tar + is_service_active docker || die "docker.service is not running." + # A foreign listener on 8123 is fatal; our own container is a re-run. + if ss -ltn 2>/dev/null | grep -qE "[:.]${HA_PORT}[[:space:]]" && + ! docker ps --format '{{.Names}}' | grep -qx "${HA_CONTAINER}"; then + die "Port ${HA_PORT} is taken by something other than ${HA_CONTAINER}." + fi +} + +write_compose() { + mkdir -p "${HA_CONFIG}" + cat >"${HA_COMPOSE}" <"${MIOT_STAMP}" + log_ok "Installed xiaomi_miot ${MIOT_VERSION} into ${MIOT_DIR}" +} + +wait_for_api() { + local code="" i + for ((i = 0; i < 60; i++)); do + code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${HA_PORT}/api/" || true)" + # 401 = API up and demanding auth; 200 = onboarding not done yet. + [[ ${code} == 401 || ${code} == 200 ]] && break + sleep 2 + done + [[ ${code} == 401 || ${code} == 200 ]] || die "HA API did not come up (last HTTP ${code:-none})." + log_ok "HA answers on http://127.0.0.1:${HA_PORT} (HTTP ${code})." +} + +setup_cmd() { + preflight + write_compose + install_miot + docker compose -f "${HA_COMPOSE}" up -d + # A freshly copied custom component is only loaded at start. + docker restart "${HA_CONTAINER}" >/dev/null + wait_for_api + log_info "Open http://:${HA_PORT} from the phone app." +} + +status_cmd() { + local rc=0 state code + state="$(docker inspect -f '{{.State.Status}}' "${HA_CONTAINER}" 2>/dev/null || echo absent)" + if [[ ${state} == running ]]; then + log_ok "container: ${state}" + else + log_error "container: ${state}" + rc=1 + fi + code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${HA_PORT}/api/" || true)" + if [[ ${code} == 401 || ${code} == 200 ]]; then + log_ok "API: HTTP ${code}" + else + log_error "API: HTTP ${code:-none}" + rc=1 + fi + if [[ -f ${MIOT_STAMP} ]]; then + log_ok "xiaomi_miot: $(<"${MIOT_STAMP}") (want ${MIOT_VERSION})" + else + log_error "xiaomi_miot: not installed" + rc=1 + fi + if sudo -n nft list chain inet filter input 2>/dev/null | grep -q "dport ${HA_PORT} accept"; then + log_ok "firewall: tcp/${HA_PORT} open to LAN/wg0" + else + log_warn "firewall: no tcp/${HA_PORT} rule (or no sudo) — sudo setup_wireguard_ssh.sh allow-homeassistant" + fi + return "${rc}" +} + +main() { + case "${1:-setup}" in + setup) setup_cmd ;; + status) status_cmd ;; + help | -h | --help) usage 0 ;; + *) usage 1 ;; + esac +} + +main "$@" diff --git a/linux_configuration/features/setup_wireguard_ssh.sh b/linux_configuration/features/setup_wireguard_ssh.sh index a653b6d0..a82d75ba 100755 --- a/linux_configuration/features/setup_wireguard_ssh.sh +++ b/linux_configuration/features/setup_wireguard_ssh.sh @@ -39,6 +39,7 @@ DUCKDNS_TOKEN="${DUCKDNS_TOKEN:-}" LAN_SUBNET="${LAN_SUBNET:-}" export ALLOW_WEB="${ALLOW_WEB:-false}" export ALLOW_DNS="${ALLOW_DNS:-false}" +export ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT:-false}" die() { log_error "$1" @@ -52,6 +53,7 @@ DUCKDNS_TOKEN="${DUCKDNS_TOKEN}" LAN_SUBNET="${LAN_SUBNET}" ALLOW_WEB="${ALLOW_WEB}" ALLOW_DNS="${ALLOW_DNS}" +ALLOW_HOMEASSISTANT="${ALLOW_HOMEASSISTANT}" EOF chmod 600 "$CONFIG_FILE" } @@ -91,7 +93,7 @@ main() { # off -- exec sudo "$0" "$@" inside require_root must re-launch with the # subcommand still present, or sudo would silently run with no args. case "$cmd" in - setup | add-peer | revoke | allow-web | allow-dns | verify | install-verify) + setup | add-peer | revoke | allow-web | allow-dns | allow-homeassistant | verify | install-verify) require_root "$@" ;; esac @@ -128,6 +130,10 @@ main() { allow_dns install_verify_units ;; + allow-homeassistant) + allow_homeassistant + install_verify_units + ;; verify) verify_nft ;;