854 lines
30 KiB
Bash
Executable File
854 lines
30 KiB
Bash
Executable File
#!/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:-<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" <<EOF
|
|
homeassistant: true
|
|
permit_join: false
|
|
mqtt:
|
|
base_topic: zigbee2mqtt
|
|
server: 'mqtt://mosquitto:1883'
|
|
serial:
|
|
port: '/dev/ttyACM0'
|
|
adapter: zstack
|
|
advanced:
|
|
network_key: GENERATE
|
|
frontend:
|
|
port: 8080
|
|
EOF
|
|
echo " NOTE: adapter set to 'zstack' for your CC2652P (Haozee/Sonoff Dongle-P style) USB dongle."
|
|
echo " If Zigbee2MQTT fails to connect, it may need CC2652P coordinator firmware flashed first"
|
|
echo " (generic-branded gateways sometimes ship with non-coordinator firmware)."
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. Frigate config (placeholder — edit before first real use)
|
|
# ---------------------------------------------------------------------------
|
|
echo "--- Writing Frigate placeholder config ---"
|
|
cat > "$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" <<EOF
|
|
- Home:
|
|
- Home Assistant:
|
|
href: http://${HOST_IP}:8123
|
|
description: Home automation hub
|
|
- Node-RED:
|
|
href: http://${HOST_IP}:1880
|
|
description: Automation flows
|
|
- Devices:
|
|
- Zigbee2MQTT:
|
|
href: http://${HOST_IP}:8080
|
|
description: Zigbee network
|
|
- Frigate:
|
|
href: http://${HOST_IP}:5000
|
|
description: NVR / face recognition
|
|
- Household:
|
|
- Grocy:
|
|
href: http://${HOST_IP}:9283
|
|
description: Inventory & shopping list
|
|
- System:
|
|
- Portainer:
|
|
href: http://${HOST_IP}:9000
|
|
description: Container management
|
|
- Netdata:
|
|
href: http://${HOST_IP}:19999
|
|
description: Host/container monitoring
|
|
- ntfy:
|
|
href: http://${HOST_IP}:8090
|
|
description: Push notifications
|
|
EOF
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. docker-compose.yml
|
|
# ---------------------------------------------------------------------------
|
|
echo "--- Writing docker-compose.yml ---"
|
|
|
|
FRIGATE_DEVICES=""
|
|
if [[ "$ENABLE_INTEL_HWACCEL" == "true" ]]; then
|
|
FRIGATE_DEVICES=" devices:
|
|
- /dev/dri:/dev/dri"
|
|
fi
|
|
|
|
MEALIE_BLOCK=""
|
|
if [[ "$ENABLE_MEALIE" == "true" ]]; then
|
|
MEALIE_BLOCK="
|
|
mealie:
|
|
image: ghcr.io/mealie-recipes/mealie:latest
|
|
container_name: mealie
|
|
restart: unless-stopped
|
|
ports:
|
|
- \"9925:9000\"
|
|
volumes:
|
|
- ${BASE_DIR}/mealie/data:/app/data
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
- ALLOW_SIGNUP=false
|
|
"
|
|
fi
|
|
|
|
NODERED_BLOCK=""
|
|
if [[ "$ENABLE_NODERED" == "true" ]]; then
|
|
NODERED_BLOCK="
|
|
nodered:
|
|
image: nodered/node-red:latest
|
|
container_name: nodered
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- mosquitto
|
|
ports:
|
|
- \"1880:1880\"
|
|
volumes:
|
|
- ${BASE_DIR}/nodered/data:/data
|
|
- /etc/localtime:/etc/localtime:ro
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
"
|
|
fi
|
|
|
|
NETDATA_BLOCK=""
|
|
if [[ "$ENABLE_NETDATA" == "true" ]]; then
|
|
NETDATA_BLOCK="
|
|
netdata:
|
|
image: netdata/netdata:stable
|
|
container_name: netdata
|
|
restart: unless-stopped
|
|
hostname: smart-home-host
|
|
network_mode: host
|
|
pid: host
|
|
cap_add:
|
|
- SYS_PTRACE
|
|
- SYS_ADMIN
|
|
security_opt:
|
|
- apparmor:unconfined
|
|
volumes:
|
|
- ${BASE_DIR}/netdata/config:/etc/netdata
|
|
- ${BASE_DIR}/netdata/lib:/var/lib/netdata
|
|
- ${BASE_DIR}/netdata/cache:/var/cache/netdata
|
|
- /etc/passwd:/host/etc/passwd:ro
|
|
- /etc/group:/host/etc/group:ro
|
|
- /etc/os-release:/host/etc/os-release:ro
|
|
- /var/log:/host/var/log:ro
|
|
- /proc:/host/proc:ro
|
|
- /sys:/host/sys:ro
|
|
- /etc/localtime:/etc/localtime:ro
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
"
|
|
fi
|
|
|
|
HOMEPAGE_BLOCK=""
|
|
if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then
|
|
HOMEPAGE_BLOCK="
|
|
homepage:
|
|
image: ghcr.io/gethomepage/homepage:latest
|
|
container_name: homepage
|
|
restart: unless-stopped
|
|
ports:
|
|
- \"3000:3000\"
|
|
volumes:
|
|
- ${BASE_DIR}/homepage/config:/app/config
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
"
|
|
fi
|
|
|
|
NTFY_BLOCK=""
|
|
if [[ "$ENABLE_NTFY" == "true" ]]; then
|
|
NTFY_BLOCK="
|
|
ntfy:
|
|
image: binwiederhier/ntfy:latest
|
|
container_name: ntfy
|
|
restart: unless-stopped
|
|
command: serve
|
|
ports:
|
|
- \"8090:80\"
|
|
volumes:
|
|
- ${BASE_DIR}/ntfy/data:/var/lib/ntfy
|
|
- ${BASE_DIR}/ntfy/config:/etc/ntfy
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
"
|
|
fi
|
|
|
|
PORTAINER_BLOCK=""
|
|
if [[ "$ENABLE_PORTAINER" == "true" ]]; then
|
|
PORTAINER_BLOCK="
|
|
portainer:
|
|
image: portainer/portainer-ce:lts
|
|
container_name: portainer
|
|
restart: unless-stopped
|
|
command: -H unix:///var/run/docker.sock
|
|
ports:
|
|
- \"9000:9000\"
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
- ${BASE_DIR}/portainer/data:/data
|
|
"
|
|
fi
|
|
|
|
GALLERY_SMB_BLOCK=""
|
|
if [[ "$ENABLE_GALLERY_SMB" == "true" ]]; then
|
|
# servercontainers/samba over dperson/samba: dperson's image has gone without
|
|
# maintainer updates long enough that forks exist specifically to patch CVEs in
|
|
# it; servercontainers is the actively maintained one as of this writing.
|
|
# ACCOUNT_gallery / SAMBA_VOLUME_CONFIG_gallery is that image's env-driven
|
|
# config surface — no config file to template, just these two variables.
|
|
GALLERY_SMB_BLOCK="
|
|
gallery-smb:
|
|
image: ghcr.io/servercontainers/samba:latest
|
|
container_name: gallery-smb
|
|
restart: unless-stopped
|
|
ports:
|
|
- \"445:445\"
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
- ACCOUNT_${GALLERY_SMB_USERNAME}=${GALLERY_SMB_PASSWORD}
|
|
- SAMBA_VOLUME_CONFIG_gallery=path = /shares/gallery; valid users = ${GALLERY_SMB_USERNAME}; guest ok = no; read only = yes; browseable = yes
|
|
volumes:
|
|
- ${BASE_DIR}/gallery:/shares/gallery
|
|
cap_add:
|
|
- NET_ADMIN
|
|
"
|
|
fi
|
|
|
|
# digest-engine and whatsapp-bridge are the FIRST locally-built images in this
|
|
# stack — everything else above pulls a prebuilt registry image. That means
|
|
# this host needs the digest-engine/ source tree present at DIGEST_ENGINE_SRC,
|
|
# and a `docker compose build` after any code change there.
|
|
DIGEST_ENGINE_BLOCK=""
|
|
DIGEST_WEB_BLOCK=""
|
|
WHATSAPP_BRIDGE_BLOCK=""
|
|
if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
|
|
# profiles: keeps this out of `docker compose up -d` — it's a oneshot driven
|
|
# by smart-home-digest.timer below. `docker compose run --rm digest-engine`
|
|
# still works regardless of profile.
|
|
DIGEST_ENGINE_BLOCK="
|
|
digest-engine:
|
|
build: ${DIGEST_ENGINE_SRC}
|
|
image: smart-home/digest-engine:local
|
|
container_name: digest-engine
|
|
profiles:
|
|
- oneshot
|
|
restart: \"no\"
|
|
env_file:
|
|
- ${BASE_DIR}/digest/digest-engine.env
|
|
volumes:
|
|
- ${BASE_DIR}/digest/output:/output
|
|
- ${BASE_DIR}/digest/data:/data
|
|
- /etc/localtime:/etc/localtime:ro
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
"
|
|
|
|
# Static, read-only serving of the rendered digest to the thin client's kiosk
|
|
# Firefox workspace and an HA Lovelace iframe card.
|
|
DIGEST_WEB_BLOCK="
|
|
digest-web:
|
|
image: nginx:alpine
|
|
container_name: digest-web
|
|
restart: unless-stopped
|
|
ports:
|
|
- \"${DIGEST_WEB_PORT}:80\"
|
|
volumes:
|
|
- ${DIGEST_ENGINE_SRC}/render/templates/compact.html:/usr/share/nginx/html/compact.html:ro
|
|
- ${DIGEST_ENGINE_SRC}/render/templates/full.html:/usr/share/nginx/html/full.html:ro
|
|
- ${DIGEST_ENGINE_SRC}/render/digest-canvas-sdk:/usr/share/nginx/html/digest-canvas-sdk:ro
|
|
- ${BASE_DIR}/digest/output:/usr/share/nginx/html/output:ro
|
|
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" <<EOF
|
|
name: smart-home
|
|
|
|
services:
|
|
homeassistant:
|
|
image: ghcr.io/home-assistant/home-assistant:stable
|
|
container_name: homeassistant
|
|
restart: unless-stopped
|
|
privileged: true
|
|
network_mode: host
|
|
volumes:
|
|
- ${BASE_DIR}/homeassistant:/config
|
|
- /etc/localtime:/etc/localtime:ro
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
|
|
mosquitto:
|
|
image: eclipse-mosquitto:2
|
|
container_name: mosquitto
|
|
restart: unless-stopped
|
|
ports:
|
|
- "1883:1883"
|
|
volumes:
|
|
- ${BASE_DIR}/mosquitto/config:/mosquitto/config
|
|
- ${BASE_DIR}/mosquitto/data:/mosquitto/data
|
|
- ${BASE_DIR}/mosquitto/log:/mosquitto/log
|
|
|
|
zigbee2mqtt:
|
|
image: koenkk/zigbee2mqtt:latest
|
|
container_name: zigbee2mqtt
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- mosquitto
|
|
ports:
|
|
- "8080:8080"
|
|
volumes:
|
|
- ${BASE_DIR}/zigbee2mqtt/data:/app/data
|
|
- /etc/localtime:/etc/localtime:ro
|
|
devices:
|
|
- ${ZIGBEE_USB_DEVICE}:/dev/ttyACM0
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
|
|
frigate:
|
|
image: ghcr.io/blakeblackshear/frigate:stable
|
|
container_name: frigate
|
|
restart: unless-stopped
|
|
privileged: true
|
|
shm_size: "256mb"
|
|
depends_on:
|
|
- mosquitto
|
|
ports:
|
|
- "5000:5000"
|
|
- "8554:8554"
|
|
- "8555:8555/tcp"
|
|
- "8555:8555/udp"
|
|
volumes:
|
|
- ${BASE_DIR}/frigate/config:/config
|
|
- ${BASE_DIR}/frigate/media:/media/frigate
|
|
- /etc/localtime:/etc/localtime:ro
|
|
${FRIGATE_DEVICES}
|
|
environment:
|
|
- TZ=${TIMEZONE}
|
|
|
|
grocy:
|
|
image: lscr.io/linuxserver/grocy:latest
|
|
container_name: grocy
|
|
restart: unless-stopped
|
|
ports:
|
|
- "9283:80"
|
|
volumes:
|
|
- ${BASE_DIR}/grocy/config:/config
|
|
environment:
|
|
- PUID=1000
|
|
- PGID=1000
|
|
- TZ=${TIMEZONE}
|
|
${MEALIE_BLOCK}${NODERED_BLOCK}${NETDATA_BLOCK}${HOMEPAGE_BLOCK}${NTFY_BLOCK}${PORTAINER_BLOCK}${GALLERY_SMB_BLOCK}${DIGEST_ENGINE_BLOCK}${DIGEST_WEB_BLOCK}${WHATSAPP_BRIDGE_BLOCK}
|
|
EOF
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. Scheduled backups (restic) — optional
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "$ENABLE_BACKUPS" == "true" ]]; then
|
|
echo "--- Setting up restic backups ---"
|
|
|
|
RESTIC_PASSWORD_FILE="$BASE_DIR/.restic-password"
|
|
if [[ ! -f "$RESTIC_PASSWORD_FILE" ]]; then
|
|
openssl rand -base64 32 > "$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" <<EOF
|
|
RESTIC_REPOSITORY=${RESTIC_REPOSITORY}
|
|
RESTIC_PASSWORD_FILE=${RESTIC_PASSWORD_FILE}
|
|
EOF
|
|
chmod 600 "$BASE_DIR/backup.env"
|
|
|
|
cat > "$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 <<EOF
|
|
[Unit]
|
|
Description=Smart home restic backup
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=${BASE_DIR}/backup.sh
|
|
EOF
|
|
|
|
cat > /etc/systemd/system/smart-home-backup.timer <<EOF
|
|
[Unit]
|
|
Description=Daily smart home restic backup
|
|
|
|
[Timer]
|
|
OnCalendar=*-*-* ${BACKUP_SCHEDULE}:00
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now smart-home-backup.timer
|
|
echo " Backup timer installed: daily at ${BACKUP_SCHEDULE}, repository: ${RESTIC_REPOSITORY}"
|
|
echo " Run it manually any time with: sudo $BASE_DIR/backup.sh"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9b. Scheduled quarter-daily digest (Phase 12) — optional, mirrors the restic
|
|
# timer above
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
|
|
echo "--- Setting up the quarter-daily digest timer ---"
|
|
|
|
cat > /etc/systemd/system/smart-home-digest.service <<EOF
|
|
[Unit]
|
|
Description=Smart home quarter-daily LLM digest
|
|
After=docker.service
|
|
Requires=docker.service
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
WorkingDirectory=${BASE_DIR}
|
|
ExecStart=/usr/bin/docker compose run --rm digest-engine
|
|
EOF
|
|
|
|
cat > /etc/systemd/system/smart-home-digest.timer <<EOF
|
|
[Unit]
|
|
Description=Quarter-daily smart home LLM digest
|
|
|
|
[Timer]
|
|
OnCalendar=*-*-* ${DIGEST_SCHEDULE}:00:00
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable --now smart-home-digest.timer
|
|
echo " Digest timer installed: 4x/day at ${DIGEST_SCHEDULE}:00."
|
|
echo " Run it manually any time with: cd $BASE_DIR && docker compose run --rm digest-engine"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 10. Bring the stack up
|
|
# ---------------------------------------------------------------------------
|
|
echo "--- Starting containers ---"
|
|
cd "$BASE_DIR"
|
|
docker compose up -d
|
|
|
|
echo
|
|
echo "=== Done ==="
|
|
echo "Services should be reachable at:"
|
|
echo " Home Assistant : http://${HOST_IP}:8123"
|
|
echo " Zigbee2MQTT UI : http://${HOST_IP}:8080"
|
|
echo " Frigate : http://${HOST_IP}:5000"
|
|
echo " Grocy : http://${HOST_IP}:9283"
|
|
if [[ "$ENABLE_MEALIE" == "true" ]]; then
|
|
echo " Mealie : http://${HOST_IP}:9925"
|
|
fi
|
|
if [[ "$ENABLE_NODERED" == "true" ]]; then
|
|
echo " Node-RED : http://${HOST_IP}:1880"
|
|
fi
|
|
if [[ "$ENABLE_NETDATA" == "true" ]]; then
|
|
echo " Netdata : http://${HOST_IP}:19999"
|
|
fi
|
|
if [[ "$ENABLE_HOMEPAGE" == "true" ]]; then
|
|
echo " Homepage : http://${HOST_IP}:3000"
|
|
fi
|
|
if [[ "$ENABLE_NTFY" == "true" ]]; then
|
|
echo " ntfy : http://${HOST_IP}:8090"
|
|
fi
|
|
if [[ "$ENABLE_PORTAINER" == "true" ]]; then
|
|
echo " Portainer : http://${HOST_IP}:9000"
|
|
fi
|
|
if [[ "$ENABLE_DIGEST_ENGINE" == "true" ]]; then
|
|
echo " Digest (full) : http://${HOST_IP}:${DIGEST_WEB_PORT}/full.html"
|
|
echo " Digest (compact): http://${HOST_IP}:${DIGEST_WEB_PORT}/compact.html"
|
|
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:+<password>}"
|
|
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)"
|