#!/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 # # Every value here is `${VAR:-default}`, so anything already in the environment wins. # That is how the ISO built by tools/build-container-host-iso.sh configures this # script without editing it: the first-boot unit sources a generated env file derived # from CoreSystemConfig.json, and these defaults apply only to a hand-run install. # Edit them directly ONLY if you are running this script standalone. # --------------------------------------------------------------------------- BASE_DIR="${BASE_DIR:-/opt/smart-home}" # Where all container config/data will live TIMEZONE="${TIMEZONE:-Europe/Vienna}" # Adjust to your timezone ENABLE_MEALIE="${ENABLE_MEALIE:-false}" # Set to "true" to also deploy Mealie ENABLE_INTEL_HWACCEL="${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="${ENABLE_NODERED:-true}" ENABLE_NETDATA="${ENABLE_NETDATA:-true}" ENABLE_HOMEPAGE="${ENABLE_HOMEPAGE:-true}" ENABLE_NTFY="${ENABLE_NTFY:-true}" ENABLE_PORTAINER="${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="${ENABLE_GALLERY_SMB:-false}" GALLERY_SMB_USERNAME="${GALLERY_SMB_USERNAME:-gallery}" GALLERY_SMB_PASSWORD="${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="${ENABLE_BACKUPS:-false}" RESTIC_REPOSITORY="${RESTIC_REPOSITORY:-/mnt/backup/smart-home-restic}" BACKUP_SCHEDULE="${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="${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="${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="${ENABLE_WHATSAPP_INGEST:-false}" # Where this repo's digest-engine/ directory lives on THIS host (build context). DIGEST_ENGINE_SRC="${DIGEST_ENGINE_SRC:-/opt/smart-home/src/digest-engine}" DIGEST_WEB_PORT="${DIGEST_WEB_PORT:-8091}" # LAN-facing read-only static serving DIGEST_SCHEDULE="${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="${ENABLE_ADMIN_CANVAS:-false}" # Where this repo's admin-canvas/ directory lives on THIS host (build context). ADMIN_CANVAS_SRC="${ADMIN_CANVAS_SRC:-/opt/smart-home/src/admin-canvas}" ADMIN_CANVAS_PORT="${ADMIN_CANVAS_PORT:-8092}" # internal only — no `ports:` mapping, HA-reachable only ADMIN_WEB_PORT="${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="${ENABLE_PANTRY_VISION:-false}" # Where this repo's pantry-vision/ directory lives on THIS host (build context). PANTRY_VISION_SRC="${PANTRY_VISION_SRC:-/opt/smart-home/src/pantry-vision}" PANTRY_VISION_PORT="${PANTRY_VISION_PORT:-8095}" # LAN-facing — the kitchen display's kiosk browser calls this directly PANTRY_WEB_PORT="${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="${ENABLE_IDENTITY:-false}" # --- Reverse proxy (Caddy) — one HTTPS front door for this repo's own services, plus # --- an HTTP->HTTPS redirect. See proxy/README.md for why it isn't just hygiene: the # --- admin panel's token rides in a URL, getUserMedia needs a secure context, and a # --- mixed-content page can't call its own API. Home Assistant and the rest keep their # --- own ports and stay off it. ENABLE_PROXY="${ENABLE_PROXY:-false}" PROXY_HOSTNAME="${PROXY_HOSTNAME:-}" PROXY_TLS="${PROXY_TLS:-internal}" # internal | custom PROXY_CERT_FILE="${PROXY_CERT_FILE:-}" # host paths, only used when PROXY_TLS=custom PROXY_KEY_FILE="${PROXY_KEY_FILE:-}" PROXY_HTTP_PORT="${PROXY_HTTP_PORT:-80}" PROXY_HTTPS_PORT="${PROXY_HTTPS_PORT:-443}" # Where this repo's identity/ directory lives on THIS host (build context). IDENTITY_SRC="${IDENTITY_SRC:-/opt/smart-home/src/identity}" IDENTITY_PORT="${IDENTITY_PORT:-8097}" # LAN-facing — kiosk browsers call this directly IDENTITY_WEB_PORT="${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="${ENABLE_TRASH_CALENDAR:-false}" # Where this repo's trash-calendar/ directory lives on THIS host (build context). TRASH_CALENDAR_SRC="${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="${ENABLE_TRANSIT:-false}" # Where this repo's transit/ directory lives on THIS host (build context). TRANSIT_SRC="${TRANSIT_SRC:-/opt/smart-home/src/transit}" TRANSIT_PORT="${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="${ENABLE_TRIP_PLANNING:-false}" OTP_GRAPHS_DIR="${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="${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="${ENABLE_CHORES:-false}" # Where this repo's chores/ directory lives on THIS host (build context). CHORES_SRC="${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="${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." echo " IDENTITY_TOKEN there is the same shared secret chores.env uses; without it the" echo " run can't read who wants which digest and generates all four sections." fi # The OPNsense IDS config. An image build writes this from CoreSystemConfig.json's # opnsense block; this branch is the hand-install path, where the template is the # only thing there is. Never overwritten — a real config outranks a template. if [[ ! -f "$BASE_DIR/digest/data/IDSconf.json" && -f "$DIGEST_ENGINE_SRC/IDSconf.json.example" ]]; then cp "$DIGEST_ENGINE_SRC/IDSconf.json.example" "$BASE_DIR/digest/data/IDSconf.json" chmod 600 "$BASE_DIR/digest/data/IDSconf.json" echo " Seeded $BASE_DIR/digest/data/IDSconf.json from the template — fill in base_url," echo " api_key and api_secret (OPNsense: System -> Access -> Users -> API keys) and set" echo " ENABLE_OPNSENSE_IDS_INGEST=true if you want the network digest." 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="" PROXY_BLOCK="" if [[ "$ENABLE_PROXY" == "true" ]]; then if [[ -z "$PROXY_HOSTNAME" ]]; then echo "ENABLE_PROXY is true but PROXY_HOSTNAME is empty — the certificate needs a name." >&2 exit 1 fi mkdir -p "$BASE_DIR"/proxy/{config,data} # The Caddyfile is generated from CoreSystemConfig.json by the ISO builder. When this # script is run by hand, a minimal equivalent is written here so the proxy still comes # up — same routes, same reasoning, just without the config file to derive from. if [[ ! -f "$BASE_DIR/proxy/config/Caddyfile" ]]; then PROXY_TLS_LINE=" tls internal" [[ "$PROXY_TLS" == "custom" ]] && PROXY_TLS_LINE=" tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem" cat > "$BASE_DIR/proxy/config/Caddyfile" < "$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=" if [[ "$ENABLE_PROXY" == "true" ]]; then echo " Admin panel : https://${PROXY_HOSTNAME}/admin.html?api=https://${PROXY_HOSTNAME}/api/identity&token=" echo " (HTTP redirects to HTTPS. With tls=internal, install the root" echo " CA first or the browser will warn: tools/export-proxy-ca.sh)" else echo " Admin panel : http://${HOST_IP}:${IDENTITY_WEB_PORT}/admin.html?api=http://${HOST_IP}:${IDENTITY_PORT}&token=" echo " NOTE: plain HTTP puts the admin token on the wire in clear." echo " Set ENABLE_PROXY=true for HTTPS — see proxy/README.md" fi echo " (people/guests, pruning, visit history, device rights —" echo " NOT a kiosk page; keep this URL off the wall panels)" 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 " tools/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)"