#!/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) # - admin-canvas + admin-web (Phase 13 sys-admin-llm display surface, optional, # off by default — needs admin-canvas/ from this repo checked out on this # host, see ADMIN_CANVAS_SRC below and admin-canvas/README.md) # - pantry-vision + pantry-web (Phase 17 kitchen-display camera cataloguing # backend, optional, off by default — needs pantry-vision/ from this repo # checked out on this host, see PANTRY_VISION_SRC below and # pantry-vision/README.md) # - identity + identity-web (Phase 6 person <-> BLE-identifier registry — # multi-phone support, anti-spoofing registration, presence/weather for # hosts/door-panel's and hosts/kitchen-display's dashboards — optional, off by # default, needs identity/ from this repo checked out on this host, see # IDENTITY_SRC below and identity/README.md) # - trash-calendar (Phase 19, optional, off by default — syncs Kennelbach's # personal collection-date ICS feed into the household's own Nextcloud # calendar, daily via systemd timer, needs trash-calendar/ from this repo # checked out on this host, see TRASH_CALENDAR_SRC below and # trash-calendar/README.md) # - transit + optional otp (Phase 19, optional, off by default — "when's the # next bus" voice lookups from a weekly-refreshed GTFS feed, plus optional # on-demand route planning via a self-hosted OpenTripPlanner; needs transit/ # from this repo checked out on this host, see TRANSIT_SRC below and # transit/README.md) # # 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 # --- On-demand sys-admin-llm display surface (Phase 13) — off by default until # --- ADMIN_CANVAS_TOKEN is provisioned. See admin-canvas/README.md. ENABLE_ADMIN_CANVAS="false" # Where this repo's admin-canvas/ directory lives on THIS host (build context). ADMIN_CANVAS_SRC="/opt/smart-home/src/admin-canvas" ADMIN_CANVAS_PORT="8092" # internal only — no `ports:` mapping, HA-reachable only ADMIN_WEB_PORT="8094" # LAN-facing read-only static serving # --- Kitchen-display camera cataloguing backend (Phase 17) — off by default until # --- PANTRY_VISION_TOKEN and GROCY_API_KEY are provisioned. See pantry-vision/README.md. # Unlike admin-canvas, this one IS meant to be reached directly by a separate physical # device (hosts/kitchen-display's kiosk browser), not only by Home Assistant — so, # deliberately unlike ADMIN_CANVAS_PORT above, it DOES get a `ports:` mapping. The # bearer token is the actual boundary here, not network placement — see # pantry-vision/README.md's "A real network listener, unlike admin-canvas" section. ENABLE_PANTRY_VISION="false" # Where this repo's pantry-vision/ directory lives on THIS host (build context). PANTRY_VISION_SRC="/opt/smart-home/src/pantry-vision" PANTRY_VISION_PORT="8095" # LAN-facing — the kitchen display's kiosk browser calls this directly PANTRY_WEB_PORT="8096" # LAN-facing read-only static serving (the kiosk's frontend) # --- Person <-> BLE-identifier registry (Phase 6) — off by default until # --- IDENTITY_TOKEN, HA_TOKEN, and TRUSTED_ENTITY_PREFIXES are provisioned. See # --- identity/README.md. Same "published, unlike admin-canvas" reasoning as # --- ENABLE_PANTRY_VISION above — hosts/kitchen-display's and hosts/door-panel's # --- kiosk browsers call this directly. ENABLE_IDENTITY="false" # Where this repo's identity/ directory lives on THIS host (build context). IDENTITY_SRC="/opt/smart-home/src/identity" IDENTITY_PORT="8097" # LAN-facing — kiosk browsers call this directly IDENTITY_WEB_PORT="8098" # LAN-facing read-only static serving (register.html/dashboard.html) # --- Trash collection date sync (Phase 19) — off by default until WASTE_ICS_URL and # --- CALDAV_TARGET_CALENDAR are provisioned. See trash-calendar/README.md. A oneshot, # --- like digest-engine, not a listener — no port, nothing to publish. ENABLE_TRASH_CALENDAR="false" # Where this repo's trash-calendar/ directory lives on THIS host (build context). TRASH_CALENDAR_SRC="/opt/smart-home/src/trash-calendar" # --- Public transit "when's the next bus" voice lookup (Phase 19) — off by default # --- until TRANSIT_TOKEN and GTFS_FEED_URL are provisioned. See transit/README.md. # --- Published like pantry-vision/identity — homeassistant's network_mode: host # --- can't resolve container DNS names, so its rest_command needs a real port. ENABLE_TRANSIT="false" # Where this repo's transit/ directory lives on THIS host (build context). TRANSIT_SRC="/opt/smart-home/src/transit" TRANSIT_PORT="8099" # reachable by HA's rest_command (voice lookups) # --- On-demand route planning (Phase 19) — a SEPARATE opt-in from ENABLE_TRANSIT # --- above on purpose: a real OpenTripPlanner graph (OSM + GTFS) is a meaningfully # --- bigger data/hardware commitment than the small filtered GTFS DB /departures # --- uses. See transit/README.md's "Route planning scope" before turning this on — # --- "Austria-wide" is a moderate commitment, "global" is a real infrastructure # --- decision, not a flag. This script does NOT build the OTP graph for you — that's # --- a manual, one-time (per OSM/GTFS update) step; see OpenTripPlanner's own docs. ENABLE_TRIP_PLANNING="false" OTP_GRAPHS_DIR="/opt/smart-home/otp-graphs" # you populate this by hand, see above # Host-side published port for OTP's own web/GraphQL API. NOT 8080 — zigbee2mqtt's # frontend (always-on, below) already publishes 8080:8080; OTP's own container- # internal port stays 8080 regardless (transit.env.example's OTP_URL correctly # reaches it via container DNS as http://otp:8080), only the host-side mapping # needed to move to avoid the two colliding on the same host. OTP_PORT="8100" # --- Household chore distribution + reminders + camera verification (Phase 20) — # --- off by default. Works with just ENABLE_IDENTITY on (assignment) and ntfy # --- (reminders); the camera-check and trash-day-eve steps each individually # --- no-op until their own env vars are set — see chores/README.md. ENABLE_CHORES="false" # Where this repo's chores/ directory lives on THIS host (build context). CHORES_SRC="/opt/smart-home/src/chores" # --- Music Assistant (optional, additive — docs/project-plan.md §2) --------- # Unifies Spotify Connect + other sources behind one HA-native multi-room player. # Runs as its OWN container (official image, not an HA add-on — this stack is a # plain HA Container install, which has no add-on store) — configure HA's own # "Music Assistant" integration afterwards to point at it. Does not replace or # depend on any host's existing per-room spotifyd/librespot/Spotify-client setup # (Phase 11.6/15/16); those keep working standalone either way. network_mode: host # because player discovery (Chromecast/AirPlay/Sonos-style mDNS) needs it — same # reasoning as the homeassistant service itself using host networking. # # VERIFY: Music Assistant's own default web UI/API port is ASSUMED to be 8095, # not confirmed against a running instance — and 8095 is already this stack's # PANTRY_VISION_PORT (below). Because network_mode: host bypasses Compose's own # port-mapping/collision-checking entirely, enabling both together with an actual # port clash would just mean one of them fails to bind, not an obvious error. # Check Music Assistant's own docs/config for how to change its listen port # BEFORE flipping this on if ENABLE_PANTRY_VISION is also true — the sanity check # below only warns, it does not change either port for you. ENABLE_MUSIC_ASSISTANT="false" # --------------------------------------------------------------------------- # 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_ADMIN_CANVAS" == "true" && ! -d "$ADMIN_CANVAS_SRC" ]]; then echo "Warning: ENABLE_ADMIN_CANVAS=true but $ADMIN_CANVAS_SRC does not exist." echo " Copy or clone this repo's admin-canvas/ directory there — it is the" echo " build context for the admin-canvas image — then re-run." fi if [[ "$ENABLE_PANTRY_VISION" == "true" && ! -d "$PANTRY_VISION_SRC" ]]; then echo "Warning: ENABLE_PANTRY_VISION=true but $PANTRY_VISION_SRC does not exist." echo " Copy or clone this repo's pantry-vision/ directory there — it is the" echo " build context for the pantry-vision image — then re-run." fi if [[ "$ENABLE_IDENTITY" == "true" && ! -d "$IDENTITY_SRC" ]]; then echo "Warning: ENABLE_IDENTITY=true but $IDENTITY_SRC does not exist." echo " Copy or clone this repo's identity/ directory there — it is the build" echo " context for the identity image — then re-run." fi if [[ "$ENABLE_TRASH_CALENDAR" == "true" && ! -d "$TRASH_CALENDAR_SRC" ]]; then echo "Warning: ENABLE_TRASH_CALENDAR=true but $TRASH_CALENDAR_SRC does not exist." echo " Copy or clone this repo's trash-calendar/ directory there — it is the" echo " build context for the trash-calendar image — then re-run." fi if [[ "$ENABLE_TRANSIT" == "true" && ! -d "$TRANSIT_SRC" ]]; then echo "Warning: ENABLE_TRANSIT=true but $TRANSIT_SRC does not exist." echo " Copy or clone this repo's transit/ directory there — it is the build" echo " context for the transit image — then re-run." fi if [[ "$ENABLE_TRIP_PLANNING" == "true" && ! -d "$OTP_GRAPHS_DIR" ]]; then echo "Warning: ENABLE_TRIP_PLANNING=true but $OTP_GRAPHS_DIR does not exist or is" echo " empty. OpenTripPlanner needs a built graph there before it can serve" echo " anything — see transit/README.md's 'Route planning scope' section. The" echo " otp container will start but /plan will fail until a graph exists." fi if [[ "$ENABLE_CHORES" == "true" && ! -d "$CHORES_SRC" ]]; then echo "Warning: ENABLE_CHORES=true but $CHORES_SRC does not exist." echo " Copy or clone this repo's chores/ directory there — it is the build" echo " context for the chores image — then re-run." fi if [[ "$ENABLE_MUSIC_ASSISTANT" == "true" && "$ENABLE_PANTRY_VISION" == "true" ]]; then echo "Warning: ENABLE_MUSIC_ASSISTANT=true and ENABLE_PANTRY_VISION=true together." echo " Music Assistant's own default port is ASSUMED to be $PANTRY_VISION_PORT (unverified," echo " see ENABLE_MUSIC_ASSISTANT's own comment above) — the SAME as PANTRY_VISION_PORT." echo " Because Music Assistant runs with network_mode: host, a real clash here is NOT" echo " caught by Docker Compose the way a normal port mapping would be. Check Music" echo " Assistant's own config for its listen port before starting the stack." 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 if [[ "$ENABLE_ADMIN_CANVAS" == "true" ]]; then mkdir -p "$BASE_DIR"/admin-canvas/output/media # Same handling as digest-engine.env above: seed from the committed template on # first run, 600, never committed (repo .gitignore covers *.env). if [[ ! -f "$BASE_DIR/admin-canvas/admin-canvas.env" ]]; then cp "$ADMIN_CANVAS_SRC/admin-canvas.env.example" "$BASE_DIR/admin-canvas/admin-canvas.env" chmod 600 "$BASE_DIR/admin-canvas/admin-canvas.env" echo " Seeded $BASE_DIR/admin-canvas/admin-canvas.env from the template — fill in a real ADMIN_CANVAS_TOKEN." fi fi if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then mkdir -p "$BASE_DIR"/pantry-vision # Same handling as admin-canvas.env above: seed from the committed template on # first run, 600, never committed (repo .gitignore covers *.env). if [[ ! -f "$BASE_DIR/pantry-vision/pantry-vision.env" ]]; then cp "$PANTRY_VISION_SRC/pantry-vision.env.example" "$BASE_DIR/pantry-vision/pantry-vision.env" chmod 600 "$BASE_DIR/pantry-vision/pantry-vision.env" echo " Seeded $BASE_DIR/pantry-vision/pantry-vision.env from the template — fill in a real" echo " PANTRY_VISION_TOKEN and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys)." fi fi if [[ "$ENABLE_IDENTITY" == "true" ]]; then # Unlike pantry-vision (stateless, all state lives in Grocy), identity owns its # own SQLite DB and registration photos — both need a persistent bind mount. mkdir -p "$BASE_DIR"/identity/data/photos if [[ ! -f "$BASE_DIR/identity/identity.env" ]]; then cp "$IDENTITY_SRC/identity.env.example" "$BASE_DIR/identity/identity.env" chmod 600 "$BASE_DIR/identity/identity.env" echo " Seeded $BASE_DIR/identity/identity.env from the template — fill in a real" echo " IDENTITY_TOKEN, HA_TOKEN (HA's own UI: profile -> Security -> Long-Lived" echo " Access Tokens), and TRUSTED_ENTITY_PREFIXES (Developer Tools -> States)." fi fi if [[ "$ENABLE_TRASH_CALENDAR" == "true" ]]; then mkdir -p "$BASE_DIR"/trash-calendar if [[ ! -f "$BASE_DIR/trash-calendar/trash-calendar.env" ]]; then cp "$TRASH_CALENDAR_SRC/trash-calendar.env.example" "$BASE_DIR/trash-calendar/trash-calendar.env" chmod 600 "$BASE_DIR/trash-calendar/trash-calendar.env" echo " Seeded $BASE_DIR/trash-calendar/trash-calendar.env from the template — fill in" echo " a real WASTE_ICS_URL and CALDAV_TARGET_CALENDAR (same CALDAV_* credentials as" echo " digest-engine's own ingest/caldav.py, reused — see trash-calendar/README.md)." fi fi if [[ "$ENABLE_TRANSIT" == "true" ]]; then mkdir -p "$BASE_DIR"/transit/data if [[ ! -f "$BASE_DIR/transit/transit.env" ]]; then cp "$TRANSIT_SRC/transit.env.example" "$BASE_DIR/transit/transit.env" chmod 600 "$BASE_DIR/transit/transit.env" echo " Seeded $BASE_DIR/transit/transit.env from the template — fill in a real" echo " TRANSIT_TOKEN and GTFS_FEED_URL (see transit/README.md for where to get it)." fi fi if [[ "$ENABLE_TRIP_PLANNING" == "true" ]]; then mkdir -p "$OTP_GRAPHS_DIR" fi if [[ "$ENABLE_CHORES" == "true" ]]; then mkdir -p "$BASE_DIR"/chores/data if [[ ! -f "$BASE_DIR/chores/chores.env" ]]; then cp "$CHORES_SRC/chores.env.example" "$BASE_DIR/chores/chores.env" chmod 600 "$BASE_DIR/chores/chores.env" echo " Seeded $BASE_DIR/chores/chores.env from the template — fill in IDENTITY_TOKEN" echo " and ntfy settings at minimum; camera checks and trash-day-eve are each" echo " optional, see chores/README.md." fi fi if [[ "$ENABLE_MUSIC_ASSISTANT" == "true" ]]; then mkdir -p "$BASE_DIR"/music-assistant/data 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" <homeassistant already share. See admin-canvas/README.md. ADMIN_CANVAS_BLOCK="" ADMIN_WEB_BLOCK="" if [[ "$ENABLE_ADMIN_CANVAS" == "true" ]]; then ADMIN_CANVAS_BLOCK=" admin-canvas: build: ${ADMIN_CANVAS_SRC} image: smart-home/admin-canvas:local container_name: admin-canvas restart: unless-stopped env_file: - ${BASE_DIR}/admin-canvas/admin-canvas.env volumes: - ${BASE_DIR}/admin-canvas/output:/output - /etc/localtime:/etc/localtime:ro environment: - ADMIN_CANVAS_PORT=${ADMIN_CANVAS_PORT} - TZ=${TIMEZONE} " ADMIN_WEB_BLOCK=" admin-web: image: nginx:alpine container_name: admin-web restart: unless-stopped ports: - \"${ADMIN_WEB_PORT}:80\" volumes: - ${ADMIN_CANVAS_SRC}/render/templates/canvas.html:/usr/share/nginx/html/canvas.html:ro - ${ADMIN_CANVAS_SRC}/render/canvas-sdk:/usr/share/nginx/html/canvas-sdk:ro - ${BASE_DIR}/admin-canvas/output:/usr/share/nginx/html/output:ro environment: - TZ=${TIMEZONE} " fi # pantry-vision (Phase 17) — the kitchen display's camera-cataloguing backend. Same # build/write-service shape as admin-canvas above, but see this block's own comment # where it differs (a published port, since a separate physical device calls it # directly) and pantry-vision/README.md for the full reasoning. `depends_on: grocy` # is a startup-order hint only — pantry-vision's own outbound calls already retry per # request, so a slow-starting Grocy delays the first real call, not the container. PANTRY_VISION_BLOCK="" PANTRY_WEB_BLOCK="" if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then PANTRY_VISION_BLOCK=" pantry-vision: build: ${PANTRY_VISION_SRC} image: smart-home/pantry-vision:local container_name: pantry-vision restart: unless-stopped depends_on: - grocy ports: - \"${PANTRY_VISION_PORT}:${PANTRY_VISION_PORT}\" env_file: - ${BASE_DIR}/pantry-vision/pantry-vision.env environment: - PANTRY_VISION_PORT=${PANTRY_VISION_PORT} - TZ=${TIMEZONE} " PANTRY_WEB_BLOCK=" pantry-web: image: nginx:alpine container_name: pantry-web restart: unless-stopped ports: - \"${PANTRY_WEB_PORT}:80\" volumes: - ${PANTRY_VISION_SRC}/frontend:/usr/share/nginx/html:ro environment: - TZ=${TIMEZONE} " fi # identity (Phase 6) — the person <-> BLE-identifier registry. Same published-port # reasoning as pantry-vision above (hosts/kitchen-display's and hosts/door-panel's # kiosk browsers call it directly), plus its own SQLite DB + registration-photo # volume, since unlike pantry-vision it owns its own state instead of deferring to # Grocy. `depends_on: homeassistant` is a startup-order hint only — it does NOT make # Home Assistant reachable by container name, since homeassistant runs with # `network_mode: host` and is off this compose network entirely (same situation as # Node-RED's own HA access below); identity.env's HA_URL has to be the host's real # LAN IP, not "homeassistant". IDENTITY_BLOCK="" IDENTITY_WEB_BLOCK="" if [[ "$ENABLE_IDENTITY" == "true" ]]; then IDENTITY_BLOCK=" identity: build: ${IDENTITY_SRC} image: smart-home/identity:local container_name: identity restart: unless-stopped depends_on: - homeassistant - mosquitto ports: - \"${IDENTITY_PORT}:${IDENTITY_PORT}\" env_file: - ${BASE_DIR}/identity/identity.env volumes: - ${BASE_DIR}/identity/data:/data environment: - IDENTITY_PORT=${IDENTITY_PORT} - TZ=${TIMEZONE} " IDENTITY_WEB_BLOCK=" identity-web: image: nginx:alpine container_name: identity-web restart: unless-stopped ports: - \"${IDENTITY_WEB_PORT}:80\" volumes: - ${IDENTITY_SRC}/frontend:/usr/share/nginx/html:ro environment: - TZ=${TIMEZONE} " fi # trash-calendar (Phase 19) — oneshot, driven by smart-home-trash-calendar.timer # below, same profiles:[oneshot] shape as digest-engine above. TRASH_CALENDAR_BLOCK="" if [[ "$ENABLE_TRASH_CALENDAR" == "true" ]]; then TRASH_CALENDAR_BLOCK=" trash-calendar: build: ${TRASH_CALENDAR_SRC} image: smart-home/trash-calendar:local container_name: trash-calendar profiles: - oneshot restart: \"no\" env_file: - ${BASE_DIR}/trash-calendar/trash-calendar.env environment: - TZ=${TIMEZONE} " fi # transit (Phase 19) — /departures is always-on (the server container below); # /transit-sync (weekly GTFS refresh) is a oneshot, same profiles:[oneshot] shape as # digest-engine/trash-calendar above, sharing the server's own /data volume so the # refreshed DB is immediately visible to it. TRANSIT_BLOCK="" TRANSIT_SYNC_BLOCK="" OTP_BLOCK="" if [[ "$ENABLE_TRANSIT" == "true" ]]; then TRANSIT_BLOCK=" transit: build: ${TRANSIT_SRC} image: smart-home/transit:local container_name: transit restart: unless-stopped ports: - \"${TRANSIT_PORT}:${TRANSIT_PORT}\" env_file: - ${BASE_DIR}/transit/transit.env volumes: - ${BASE_DIR}/transit/data:/data environment: - TRANSIT_PORT=${TRANSIT_PORT} - TZ=${TIMEZONE} " TRANSIT_SYNC_BLOCK=" transit-sync: build: ${TRANSIT_SRC} image: smart-home/transit:local container_name: transit-sync profiles: - oneshot restart: \"no\" command: [\"python\", \"sync_gtfs.py\"] env_file: - ${BASE_DIR}/transit/transit.env volumes: - ${BASE_DIR}/transit/data:/data environment: - TZ=${TIMEZONE} " fi if [[ "$ENABLE_TRIP_PLANNING" == "true" ]]; then # Official OpenTripPlanner image. VERIFY the exact CLI flags/serve command against # whichever OTP version you deploy — this repo does not build or manage the graph # itself, see transit/README.md's 'Route planning scope'. OTP_BLOCK=" otp: image: opentripplanner/opentripplanner:latest container_name: otp restart: unless-stopped ports: - \"${OTP_PORT}:8080\" volumes: - ${OTP_GRAPHS_DIR}:/var/opentripplanner command: [\"--load\", \"--serve\"] environment: - TZ=${TIMEZONE} " fi # chores (Phase 20) — oneshot, driven by smart-home-chores.timer below, same # profiles:[oneshot] shape as trash-calendar/transit-sync above. Reads from # identity/Frigate/ntfy over the network, not container DNS — no shared volume # needed with any of them. CHORES_BLOCK="" if [[ "$ENABLE_CHORES" == "true" ]]; then CHORES_BLOCK=" chores: build: ${CHORES_SRC} image: smart-home/chores:local container_name: chores profiles: - oneshot restart: \"no\" env_file: - ${BASE_DIR}/chores/chores.env volumes: - ${BASE_DIR}/chores/data:/data environment: - TZ=${TIMEZONE} " fi # Music Assistant — own container, host networking (see ENABLE_MUSIC_ASSISTANT's # module-level comment above for why). No published-port line needed since host # networking exposes its own default port (8095) directly. MUSIC_ASSISTANT_BLOCK="" if [[ "$ENABLE_MUSIC_ASSISTANT" == "true" ]]; then MUSIC_ASSISTANT_BLOCK=" music-assistant: image: ghcr.io/music-assistant/server:stable container_name: music-assistant restart: unless-stopped network_mode: host volumes: - ${BASE_DIR}/music-assistant/data:/data environment: - TZ=${TIMEZONE} " fi if [[ "$ENABLE_DIGEST_ENGINE" == "true" && "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then # Long-lived, unlike digest-engine: holds the logged-in WhatsApp Web session # open and appends to /data/messages.jsonl, which digest-engine drains each # run. No ports — never reachable from the LAN, only via the shared volume. # shm_size is required: Chromium's renderer crashes on Docker's default 64MB. WHATSAPP_BRIDGE_BLOCK=" whatsapp-bridge: build: ${DIGEST_ENGINE_SRC}/whatsapp-bridge image: smart-home/whatsapp-bridge:local container_name: whatsapp-bridge restart: unless-stopped shm_size: \"512mb\" volumes: - ${BASE_DIR}/digest/data/whatsapp-bridge:/data - /etc/localtime:/etc/localtime:ro environment: - TZ=${TIMEZONE} " fi cat > "$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 < /etc/systemd/system/smart-home-trash-calendar.service < /etc/systemd/system/smart-home-trash-calendar.timer < /etc/systemd/system/smart-home-transit-sync.service < /etc/systemd/system/smart-home-transit-sync.timer < /etc/systemd/system/smart-home-chores.service < /etc/systemd/system/smart-home-chores.timer <" fi if [[ "$ENABLE_IDENTITY" == "true" ]]; then echo " Identity API : http://${HOST_IP}:${IDENTITY_PORT} (bearer-token gated)" echo " Register page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/register.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=&device=" echo " Dashboard page : http://${HOST_IP}:${IDENTITY_WEB_PORT}/dashboard.html?identity_api=http://${HOST_IP}:${IDENTITY_PORT}&identity_token=" fi if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then echo " Gallery SMB : \\\\${HOST_IP}\\gallery (user: ${GALLERY_SMB_USERNAME})" fi echo echo "Next steps:" echo " 1. Edit $BASE_DIR/frigate/config/config.yml with your real peephole camera RTSP URL, then:" echo " docker compose -f $BASE_DIR/docker-compose.yml restart frigate" echo " 2. Check 'docker logs zigbee2mqtt' — if it can't open the serial port, confirm" echo " ZIGBEE_USB_DEVICE at the top of this script matches 'ls -l /dev/serial/by-id/'," echo " and that the dongle has Z-Stack coordinator firmware (not stock gateway firmware)." echo " 3. Complete Home Assistant's onboarding wizard at :8123." echo " 4. In HA, add the MQTT integration (it should auto-discover Zigbee2MQTT devices)." echo " 5. Portainer: open :9000 within a few minutes of first start to set the admin password" echo " before anyone else on the LAN can claim that instance." echo " 6. Node-RED runs in its own bridge network, but Home Assistant uses network_mode: host," echo " so Node-RED can't reach it by container name — use http://${HOST_IP}:8123 plus a" echo " long-lived access token (HA profile page) when wiring up the HA nodes." echo " 7. Add the CalDAV integration pointing at your Nextcloud instance." echo " 8. Point HA's Ollama conversation integration at your separate GPU/LLM host." if [[ "$ENABLE_BACKUPS" != "true" ]]; then echo " 9. Backups are off. Set ENABLE_BACKUPS=true and RESTIC_REPOSITORY at the top of this" echo " script and re-run once you have backup storage (external drive, NAS, or remote) ready." fi if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then echo " 10. Fill in $BASE_DIR/digest/digest-engine.env before the first digest run." echo " 11. Telegram needs a one-time interactive login:" echo " cd $BASE_DIR && docker compose run --rm --entrypoint python digest-engine ingest/telegram_login.py" if [[ "$ENABLE_WHATSAPP_INGEST" == "true" ]]; then echo " 12. WhatsApp needs a one-time QR scan: docker compose logs -f whatsapp-bridge" fi echo " Add the compact view to HA as a Lovelace iframe card pointing at" echo " http://${HOST_IP}:${DIGEST_WEB_PORT}/compact.html" fi if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then echo " 13. Copy photos into $BASE_DIR/gallery — an empty share is harmless, thin clients" echo " just skip the idle slideshow until there's something in it." echo " On each thin client, put the SAME ${GALLERY_SMB_USERNAME}/${GALLERY_SMB_PASSWORD:+}" echo " you set at the top of this script into /etc/thinclient-agent/gallery-credentials" echo " — see hosts/thin-client/README.md." fi if [[ "$ENABLE_ADMIN_CANVAS" == "true" ]]; then echo " 14. Fill in $BASE_DIR/admin-canvas/admin-canvas.env before the sys-admin-llm can push" echo " anything (ADMIN_CANVAS_TOKEN is required — the service rejects every request" echo " while it is empty). Paste the same token into HA's rest_command: config — see" echo " admin-canvas/README.md for the worked example and the 'Show admin canvas'" echo " entity documented in hosts/thin-client/README.md." fi if [[ "$ENABLE_PANTRY_VISION" == "true" ]]; then echo " 15. Fill in $BASE_DIR/pantry-vision/pantry-vision.env before the kitchen display can" echo " identify anything: PANTRY_VISION_TOKEN (also goes into" echo " hosts/kitchen-display/scripts/build-kitchen-display-iso.sh — both sides need the" echo " SAME value) and GROCY_API_KEY (Grocy's own UI: Settings -> Manage API keys, at" echo " http://${HOST_IP}:9283). Also pull a vision-capable Ollama model on the LLM host" echo " (e.g. 'ollama pull llava') — OLLAMA_VISION_MODEL defaults to one that is NOT" echo " confirmed pulled or even correct for your setup. See pantry-vision/README.md." fi if [[ "$ENABLE_IDENTITY" == "true" ]]; then echo " 16. Fill in $BASE_DIR/identity/identity.env before registration/presence can work:" echo " IDENTITY_TOKEN (also goes into hosts/kitchen-display's and" echo " hosts/door-panel's build scripts — every side needs the SAME value), HA_URL" echo " (this host's real LAN IP, NOT 'homeassistant' — see the .env template's own" echo " comment for why), HA_TOKEN (HA's own UI: profile -> Security -> Long-Lived" echo " Access Tokens), and TRUSTED_ENTITY_PREFIXES (Developer Tools -> States, after" echo " Bermuda's Private BLE Device integration and/or fixed BLE tags are set up —" echo " see docs/project-plan.md §1.5). See identity/README.md for the worked HA" echo " custom-sentence/intent-script example that makes 'register me as ' work." 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)"