#!/usr/bin/env bash # # Smart Home Container Host Setup # Target: Debian 12 (Bookworm) or Raspberry Pi OS Lite (Bookworm-based) # # Sets up Docker + a compose stack for everything that lives on this single # host per docs/project-plan.md Phase 1 and Phase 9 (the LLM/GPU host is a # separate physical machine — see hosts/llm-host): # - Home Assistant (Container install) # - Mosquitto (MQTT broker) # - Zigbee2MQTT (talks to your USB-attached CC2652P/Haozee coordinator) # - Node-RED (automation glue, identity-correlation flow home) # - Frigate (NVR + face recognition, for the peephole cam) # - Grocy (self-hosted inventory / shopping list) # - Mealie (optional meal planning) # - Netdata (host + container monitoring) # - Homepage (single dashboard landing page) # - ntfy (self-hosted push notifications) # - Portainer (Docker GUI) # - gallery-smb (optional, off by default — read-only SMB share of photos for the # Phase 11 thin clients' idle slideshow) # - restic scheduled backups (optional, off by default) # - digest-engine + digest-web (Phase 12 quarter-daily LLM digest, optional, # off by default — needs digest-engine/ from this repo checked out on this # host, see DIGEST_ENGINE_SRC below and digest-engine/README.md) # - whatsapp-bridge (optional, off by default, gated separately — real ban # risk, see digest-engine/README.md before enabling) # # Run as: sudo ./setup-container-host.sh # # EDIT THE VARIABLES BELOW BEFORE RUNNING. set -euo pipefail # --------------------------------------------------------------------------- # CONFIGURATION — edit these before running # --------------------------------------------------------------------------- BASE_DIR="/opt/smart-home" # Where all container config/data will live TIMEZONE="Europe/Vienna" # Adjust to your timezone ENABLE_MEALIE="false" # Set to "true" to also deploy Mealie ENABLE_INTEL_HWACCEL="false" # Set to "true" if this host has an Intel iGPU for Frigate # --- Phase 1/9 add-ons — on by default, set to "false" to skip any of them --- ENABLE_NODERED="true" ENABLE_NETDATA="true" ENABLE_HOMEPAGE="true" ENABLE_NTFY="true" ENABLE_PORTAINER="true" # --- Gallery SMB share — off by default until a password is chosen ---------- # Serves $BASE_DIR/gallery read-only over SMB so idle thin clients cycle through photos # instead of blanking (Phase 11). Unlike the restic password below, this one is NOT # auto-generated: the identical value has to be typed into # /etc/thinclient-agent/gallery-credentials on every thin client, so a secret only this # script ever saw would be a secret the other end cannot have. Pick one yourself. ENABLE_GALLERY_SMB="false" GALLERY_SMB_USERNAME="gallery" GALLERY_SMB_PASSWORD="" # <-- SET THIS before flipping the toggle above # --- Scheduled backups (restic) — off by default until you pick a target --- # Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY to a local path (e.g. an # external/USB drive mount, or a NAS mount), or a remote target restic # supports (s3:..., sftp:..., b2:..., rest:...). See https://restic.net ENABLE_BACKUPS="false" RESTIC_REPOSITORY="/mnt/backup/smart-home-restic" BACKUP_SCHEDULE="03:30" # systemd OnCalendar time, daily at this local time # --- Zigbee adapter: USB (CC2652P/CH340C, e.g. Haozee/Sonoff Dongle-P style) --- # Run `ls -l /dev/serial/by-id/` AFTER plugging the adapter in, and paste the # full path it shows here. This is more stable across reboots than /dev/ttyUSB0. # Example: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 ZIGBEE_USB_DEVICE="/dev/serial/by-id/usb-1a86_USB_Serial-if00-port0" # --- Quarter-daily LLM digest (Phase 12) — off by default until credentials # --- are provisioned. See digest-engine/README.md. ENABLE_DIGEST_ENGINE="false" # WhatsApp ingestion is separately gated and highest-risk of the four message # platforms. Read digest-engine/README.md before setting this to "true" — real # ban risk even with the headful-Chromium mitigation; use a secondary number. ENABLE_WHATSAPP_INGEST="false" # Where this repo's digest-engine/ directory lives on THIS host (build context). DIGEST_ENGINE_SRC="/opt/smart-home/src/digest-engine" DIGEST_WEB_PORT="8091" # LAN-facing read-only static serving DIGEST_SCHEDULE="00,06,12,18" # systemd OnCalendar hours, 4x/day # --------------------------------------------------------------------------- # Sanity checks # --------------------------------------------------------------------------- if [[ $EUID -ne 0 ]]; then echo "Please run as root (sudo ./setup-container-host.sh)" >&2 exit 1 fi if ! grep -qi "debian" /etc/os-release; then echo "Warning: this script targets Debian/Raspberry Pi OS. Proceeding anyway..." fi if [[ ! -e "$ZIGBEE_USB_DEVICE" ]]; then echo "Warning: $ZIGBEE_USB_DEVICE does not exist yet." echo " Plug in your Zigbee adapter and run 'ls -l /dev/serial/by-id/' to find the correct path," echo " then edit ZIGBEE_USB_DEVICE at the top of this script before re-running." echo " Continuing anyway — the zigbee2mqtt container just won't start correctly until this is fixed." fi if [[ "$ENABLE_BACKUPS" == "true" && "$RESTIC_REPOSITORY" == "/mnt/backup/smart-home-restic" ]]; then echo "Warning: ENABLE_BACKUPS=true but RESTIC_REPOSITORY is still the placeholder path." echo " Edit RESTIC_REPOSITORY at the top of this script to point at real backup storage." fi if [[ "$ENABLE_DIGEST_ENGINE" == "true" && ! -d "$DIGEST_ENGINE_SRC" ]]; then echo "Warning: ENABLE_DIGEST_ENGINE=true but $DIGEST_ENGINE_SRC does not exist." echo " Copy or clone this repo's digest-engine/ directory there — it is the" echo " build context for the digest-engine image — then re-run." fi if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then echo "WARNING: WhatsApp ingestion is enabled." echo " There is no officially sanctioned way to read WhatsApp programmatically." echo " whatsapp-bridge runs a real headful Chromium logged in as a linked device," echo " which lowers but does not remove the ban risk (~2-8 weeks, historically)." echo " Use a secondary/non-critical number. See digest-engine/README.md." fi if [[ "$ENABLE_GALLERY_SMB" == "true" && -z "$GALLERY_SMB_PASSWORD" ]]; then echo "Warning: ENABLE_GALLERY_SMB=true but GALLERY_SMB_PASSWORD is still empty." echo " Set a real password at the top of this script — the same value then has to be" echo " typed into /etc/thinclient-agent/gallery-credentials on every thin client that" echo " should mount this share, since nothing here can push it to those machines." fi HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" HOST_IP="${HOST_IP:-}" echo "=== Smart Home Container Host Setup ===" echo "Base directory: $BASE_DIR" echo "Timezone: $TIMEZONE" echo "Host IP (detected): $HOST_IP" echo "Zigbee adapter (USB): ${ZIGBEE_USB_DEVICE}" echo # --------------------------------------------------------------------------- # 1. System update + prerequisites # --------------------------------------------------------------------------- echo "--- Updating system and installing prerequisites ---" apt-get update apt-get upgrade -y apt-get install -y \ ca-certificates \ curl \ gnupg \ lsb-release \ jq \ openssl if [[ "$ENABLE_BACKUPS" == "true" ]]; then apt-get install -y restic fi # --------------------------------------------------------------------------- # 2. Install Docker Engine + Compose plugin (official Docker repo) # --------------------------------------------------------------------------- if ! command -v docker &> /dev/null; then echo "--- Installing Docker Engine ---" install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc ARCH="$(dpkg --print-architecture)" CODENAME="$(. /etc/os-release && echo "$VERSION_CODENAME")" echo \ "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${CODENAME} stable" \ > /etc/apt/sources.list.d/docker.list apt-get update apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # Allow the invoking (non-root) user to run docker without sudo, if applicable if [[ -n "${SUDO_USER:-}" ]]; then usermod -aG docker "$SUDO_USER" echo "Added $SUDO_USER to the docker group. Log out/in for it to take effect." fi else echo "--- Docker already installed, skipping ---" fi # --------------------------------------------------------------------------- # 3. Directory structure # --------------------------------------------------------------------------- echo "--- Creating directory structure under $BASE_DIR ---" mkdir -p "$BASE_DIR"/{homeassistant,mosquitto/config,mosquitto/data,mosquitto/log,zigbee2mqtt/data,frigate/config,frigate/media,grocy/config,grocy/data} if [[ "$ENABLE_MEALIE" == "true" ]]; then mkdir -p "$BASE_DIR"/mealie/data fi if [[ "$ENABLE_NODERED" == "true" ]]; then mkdir -p "$BASE_DIR"/nodered/data fi if [[ "$ENABLE_NETDATA" == "true" ]]; then mkdir -p "$BASE_DIR"/netdata/{config,lib,cache} fi if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then mkdir -p "$BASE_DIR"/homepage/config fi if [[ "$ENABLE_NTFY" == "true" ]]; then mkdir -p "$BASE_DIR"/ntfy/{data,config} fi if [[ "$ENABLE_PORTAINER" == "true" ]]; then mkdir -p "$BASE_DIR"/portainer/data fi if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then # The share root itself. Nothing seeds it with photos — that's a human copying # files in; an empty share is a valid, harmless state (the thin-client slideshow # skips idle-gallery mode with nothing to show, same as an unreachable share). mkdir -p "$BASE_DIR"/gallery fi if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then mkdir -p "$BASE_DIR"/digest/{output,data} if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then mkdir -p "$BASE_DIR"/digest/data/whatsapp-bridge fi # The real credentials file. Seed it from the committed template on first run; # 600 like backup.env, and never committed (repo .gitignore covers *.env). if [[ ! -f "$BASE_DIR/digest/digest-engine.env" ]]; then cp "$DIGEST_ENGINE_SRC/digest-engine.env.example" "$BASE_DIR/digest/digest-engine.env" chmod 600 "$BASE_DIR/digest/digest-engine.env" echo " Seeded $BASE_DIR/digest/digest-engine.env from the template — fill in real values." fi fi # --------------------------------------------------------------------------- # 4. Mosquitto config # --------------------------------------------------------------------------- echo "--- Writing Mosquitto config ---" cat > "$BASE_DIR/mosquitto/config/mosquitto.conf" <<'EOF' # Basic internal-network broker config. # This trusts anything on your Docker/LAN network. If Mosquitto will ever be # reachable beyond your trusted LAN, add password_file auth before exposing it. listener 1883 allow_anonymous true persistence true persistence_location /mosquitto/data/ log_dest file /mosquitto/log/mosquitto.log EOF # --------------------------------------------------------------------------- # 5. Zigbee2MQTT config (pre-seeded for your USB CC2652P dongle) # --------------------------------------------------------------------------- echo "--- Writing Zigbee2MQTT config ---" cat > "$BASE_DIR/zigbee2mqtt/data/configuration.yaml" < "$BASE_DIR/frigate/config/config.yml" <<'EOF' mqtt: host: mosquitto port: 1883 # Face recognition (Frigate 0.16+) face_recognition: enabled: true cameras: peephole: ffmpeg: inputs: - path: rtsp://USERNAME:PASSWORD@CAMERA_IP:554/STREAM_PATH roles: - detect - record detect: width: 1280 height: 720 fps: 5 record: enabled: true # Uncomment and adjust if using Intel QuickSync/OpenVINO hardware acceleration: # ffmpeg: # hwaccel_args: preset-vaapi # detectors: # ov: # type: openvino # device: GPU EOF echo " NOTE: edit $BASE_DIR/frigate/config/config.yml with your real camera RTSP URL before starting Frigate." # --------------------------------------------------------------------------- # 7. Homepage dashboard config (single landing page over the whole stack) # --------------------------------------------------------------------------- if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then echo "--- Writing Homepage config ---" cat > "$BASE_DIR/homepage/config/settings.yaml" <<'EOF' title: Smart Home theme: dark color: slate headerStyle: clean EOF cat > "$BASE_DIR/homepage/config/widgets.yaml" <<'EOF' - resources: cpu: true memory: true disk: / EOF cat > "$BASE_DIR/homepage/config/bookmarks.yaml" <<'EOF' [] EOF cat > "$BASE_DIR/homepage/config/services.yaml" < "$BASE_DIR/docker-compose.yml" < "$RESTIC_PASSWORD_FILE" chmod 600 "$RESTIC_PASSWORD_FILE" echo " Generated a new restic repository password at $RESTIC_PASSWORD_FILE." echo " BACK THIS FILE UP SOMEWHERE ELSE — losing it makes the backup repo unreadable." fi cat > "$BASE_DIR/backup.env" < "$BASE_DIR/backup.sh" <<'BACKUP_EOF' #!/usr/bin/env bash # Backs up all stateful smart-home volumes with restic. # Stops the stack briefly for a consistent snapshot of SQLite-backed configs # (HA, Zigbee2MQTT, Grocy, Node-RED), then restarts it. # Frigate's recorded video is excluded — it's large and non-critical to keep; # face-recognition embeddings live under frigate/config, which IS backed up. set -euo pipefail BASE_DIR="/opt/smart-home" source "$BASE_DIR/backup.env" cd "$BASE_DIR" if ! restic snapshots --no-lock >/dev/null 2>&1; then echo "Initializing new restic repository at $RESTIC_REPOSITORY" restic init fi echo "Stopping stack for a consistent backup..." docker compose stop restic backup "$BASE_DIR" \ --exclude "$BASE_DIR/frigate/media" \ --exclude "$BASE_DIR/.restic-password" \ --exclude "$BASE_DIR/backup.env" echo "Restarting stack..." docker compose start restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune BACKUP_EOF chmod +x "$BASE_DIR/backup.sh" cat > /etc/systemd/system/smart-home-backup.service < /etc/systemd/system/smart-home-backup.timer < /etc/systemd/system/smart-home-digest.service < /etc/systemd/system/smart-home-digest.timer <}" echo " you set at the top of this script into /etc/thinclient-agent/gallery-credentials" echo " — see hosts/thin-client/README.md." fi echo echo "Updating later: cd $BASE_DIR && docker compose pull && docker compose up -d" echo "Backing up manually: sudo $BASE_DIR/backup.sh (requires ENABLE_BACKUPS=true was run once)"