Home Assistant setup + allow-homeassistant firewall option (Viomi V3 LAN control)

setup_homeassistant.sh runs a digest-pinned Home Assistant container with
host networking and installs hass-xiaomi-miot v1.1.5, so the Viomi V3
(viomi.vacuum.v13) is controllable over the LAN from the HA companion app.
setup_wireguard_ssh.sh gains a persisted ALLOW_HOMEASSISTANT flag that opens
tcp/8123 to the LAN subnet and wg0 only. Prunes the oldest evidence file to
stay under the 60-file cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGnk48tUeJteJWEQ7UhKDd
This commit is contained in:
Krzysztof Rudnicki 2026-09-27 17:12:22 +02:00
parent 6fec606368
commit 07810fb270
7 changed files with 263 additions and 53 deletions

View File

@ -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"
}

View File

@ -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 <LAN> 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"
]
}

View File

@ -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"
]
}

View File

@ -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" <<EOF
#!/usr/sbin/nft -f
flush ruleset
@ -46,7 +53,7 @@ table inet filter {
udp dport ${WG_PORT} accept
iifname "${WG_IFACE}" tcp dport 22 accept
ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}${dns_rule}
ip saddr ${LAN_SUBNET} tcp dport 22 accept${web_rule}${dns_rule}${ha_rule}
}
chain forward {
type filter hook forward priority 0; policy drop;

View File

@ -204,6 +204,14 @@ allow_dns() {
log_ok "Opened udp/tcp 53 from ${LAN_SUBNET:-the LAN} in the input chain (persisted -- future 'setup' re-runs keep this rule)."
}
allow_homeassistant() {
export ALLOW_HOMEASSISTANT=true
save_config
write_nftables_ruleset
verify_nftables_then_apply
log_ok "Opened tcp/8123 from ${LAN_SUBNET:-the LAN} and ${WG_IFACE} (persisted)."
}
status_cmd() {
echo "=== WireGuard ==="
wg show 2>/dev/null || echo "(interface not up)"

View File

@ -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}" <<EOF
# Managed by setup_homeassistant.sh — do not edit by hand.
services:
homeassistant:
container_name: ${HA_CONTAINER}
image: ${HA_IMAGE}
network_mode: host
restart: unless-stopped
environment:
- TZ=Europe/Warsaw
volumes:
- ${HA_CONFIG}:/config
EOF
log_ok "Wrote ${HA_COMPOSE}"
}
install_miot() {
if [[ -f ${MIOT_STAMP} && "$(<"${MIOT_STAMP}")" == "${MIOT_VERSION}" ]]; then
log_ok "xiaomi_miot ${MIOT_VERSION} already installed."
return 0
fi
TMP_DIR="$(mktemp -d)"
curl -fsSL "https://github.com/${MIOT_REPO}/archive/refs/tags/${MIOT_VERSION}.tar.gz" |
tar -xz -C "${TMP_DIR}"
local src
src="$(find "${TMP_DIR}" -maxdepth 3 -type d -path '*/custom_components/xiaomi_miot' | head -1)"
[[ -n ${src} ]] || die "custom_components/xiaomi_miot not found in ${MIOT_VERSION} tarball."
rm -rf "${MIOT_DIR}"
mkdir -p "$(dirname "${MIOT_DIR}")"
cp -r "${src}" "${MIOT_DIR}"
printf '%s' "${MIOT_VERSION}" >"${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://<this-PC-LAN-IP>:${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 "$@"

View File

@ -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
;;