#!/usr/bin/env bash # homeai-esp32/deploy.sh — Quick OTA deploy for ESP32-S3-BOX-3 satellites # # Usage: # ./deploy.sh — deploy config + images to living room (default) # ./deploy.sh bedroom — deploy to bedroom unit # ./deploy.sh --images-only — deploy existing PNGs from illustrations/ (no regen) # ./deploy.sh --regen-images — regenerate placeholder PNGs then deploy # ./deploy.sh --validate — validate config without deploying # ./deploy.sh --all — deploy to all configured units # # Images are compiled into firmware, so any PNG changes require a reflash. # To use custom images: drop 320x240 PNGs into esphome/illustrations/ then ./deploy.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ESPHOME_DIR="${SCRIPT_DIR}/esphome" ESPHOME_VENV="${HOME}/homeai-esphome-env" ESPHOME="${ESPHOME_VENV}/bin/esphome" PYTHON="${ESPHOME_VENV}/bin/python3" ILLUSTRATIONS_DIR="${ESPHOME_DIR}/illustrations" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' log_info() { echo -e "${BLUE}[INFO]${NC} $*"; } log_ok() { echo -e "${GREEN}[OK]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } log_step() { echo -e "${CYAN}[STEP]${NC} $*"; } # ─── Available units ────────────────────────────────────────────────────────── UNIT_NAMES=(living-room bedroom kitchen) DEFAULT_UNIT="living-room" unit_config() { case "$1" in living-room) echo "homeai-living-room.yaml" ;; bedroom) echo "homeai-bedroom.yaml" ;; kitchen) echo "homeai-kitchen.yaml" ;; *) echo "" ;; esac } unit_list() { echo "${UNIT_NAMES[*]}" } # ─── Face image generator ──────────────────────────────────────────────────── generate_faces() { log_step "Generating face illustrations (320x240 PNG)..." "${PYTHON}" << 'PYEOF' from PIL import Image, ImageDraw import os WIDTH, HEIGHT = 320, 240 OUT = os.environ.get("ILLUSTRATIONS_DIR", "esphome/illustrations") def draw_face(draw, eye_color, mouth_color, eye_height=40, eye_y=80, mouth_style="smile"): ex1, ey1 = 95, eye_y draw.ellipse([ex1-25, ey1-eye_height//2, ex1+25, ey1+eye_height//2], fill=eye_color) ex2, ey2 = 225, eye_y draw.ellipse([ex2-25, ey2-eye_height//2, ex2+25, ey2+eye_height//2], fill=eye_color) if mouth_style == "smile": draw.arc([110, 140, 210, 200], start=0, end=180, fill=mouth_color, width=3) elif mouth_style == "open": draw.ellipse([135, 150, 185, 190], fill=mouth_color) elif mouth_style == "flat": draw.line([120, 170, 200, 170], fill=mouth_color, width=3) elif mouth_style == "frown": draw.arc([110, 160, 210, 220], start=180, end=360, fill=mouth_color, width=3) states = { "idle": {"eye_color": "#FFFFFF", "mouth_color": "#FFFFFF", "eye_height": 40, "mouth_style": "smile"}, "loading": {"eye_color": "#6366F1", "mouth_color": "#6366F1", "eye_height": 30, "mouth_style": "flat"}, "listening": {"eye_color": "#00BFFF", "mouth_color": "#00BFFF", "eye_height": 50, "mouth_style": "open"}, "thinking": {"eye_color": "#A78BFA", "mouth_color": "#A78BFA", "eye_height": 20, "mouth_style": "flat"}, "replying": {"eye_color": "#10B981", "mouth_color": "#10B981", "eye_height": 40, "mouth_style": "open"}, "error": {"eye_color": "#EF4444", "mouth_color": "#EF4444", "eye_height": 40, "mouth_style": "frown"}, "timer_finished": {"eye_color": "#F59E0B", "mouth_color": "#F59E0B", "eye_height": 50, "mouth_style": "smile"}, } os.makedirs(OUT, exist_ok=True) for name, p in states.items(): img = Image.new("RGBA", (WIDTH, HEIGHT), (0, 0, 0, 255)) draw = ImageDraw.Draw(img) draw_face(draw, p["eye_color"], p["mouth_color"], p["eye_height"], mouth_style=p["mouth_style"]) img.save(f"{OUT}/{name}.png") print(f" {name}.png") PYEOF log_ok "Generated 7 face illustrations" } # ─── Check existing images ─────────────────────────────────────────────────── REQUIRED_IMAGES=(idle loading listening thinking replying error timer_finished) check_images() { local missing=() for name in "${REQUIRED_IMAGES[@]}"; do if [[ ! -f "${ILLUSTRATIONS_DIR}/${name}.png" ]]; then missing+=("${name}.png") fi done if [[ ${#missing[@]} -gt 0 ]]; then log_error "Missing illustrations: ${missing[*]} Place 320x240 PNGs in ${ILLUSTRATIONS_DIR}/ or use --regen-images to generate placeholders." fi log_ok "All ${#REQUIRED_IMAGES[@]} illustrations present in illustrations/" for name in "${REQUIRED_IMAGES[@]}"; do local size size=$(wc -c < "${ILLUSTRATIONS_DIR}/${name}.png" | tr -d ' ') echo -e " ${name}.png (${size} bytes)" done } # ─── Deploy to a single unit ───────────────────────────────────────────────── deploy_unit() { local unit_name="$1" local config config="$(unit_config "$unit_name")" if [[ -z "$config" ]]; then log_error "Unknown unit: ${unit_name}. Available: $(unit_list)" fi local config_path="${ESPHOME_DIR}/${config}" if [[ ! -f "$config_path" ]]; then log_error "Config not found: ${config_path}" fi log_step "Validating ${config}..." cd "${ESPHOME_DIR}" "${ESPHOME}" config "${config}" > /dev/null log_ok "Config valid" log_step "Compiling + OTA deploying ${config}..." "${ESPHOME}" run "${config}" --device OTA 2>&1 log_ok "Deployed to ${unit_name}" } # ─── Main ───────────────────────────────────────────────────────────────────── IMAGES_ONLY=false REGEN_IMAGES=false VALIDATE_ONLY=false DEPLOY_ALL=false TARGET="${DEFAULT_UNIT}" while [[ $# -gt 0 ]]; do case "$1" in --images-only) IMAGES_ONLY=true; shift ;; --regen-images) REGEN_IMAGES=true; shift ;; --validate) VALIDATE_ONLY=true; shift ;; --all) DEPLOY_ALL=true; shift ;; --help|-h) echo "Usage: $0 [unit-name] [--images-only] [--regen-images] [--validate] [--all]" echo "" echo "Units: $(unit_list)" echo "" echo "Options:" echo " --images-only Deploy existing PNGs from illustrations/ (for custom images)" echo " --regen-images Regenerate placeholder face PNGs then deploy" echo " --validate Validate config without deploying" echo " --all Deploy to all configured units" echo "" echo "Examples:" echo " $0 # deploy config to living-room" echo " $0 bedroom # deploy to bedroom" echo " $0 --images-only # deploy with current images (custom or generated)" echo " $0 --regen-images # regenerate placeholder faces + deploy" echo " $0 --all # deploy to all units" echo "" echo "Custom images: drop 320x240 PNGs into esphome/illustrations/" echo "Required files: ${REQUIRED_IMAGES[*]}" exit 0 ;; *) if [[ -n "$(unit_config "$1")" ]]; then TARGET="$1" else log_error "Unknown option or unit: $1. Use --help for usage." fi shift ;; esac done # Check ESPHome if [[ ! -x "${ESPHOME}" ]]; then log_error "ESPHome not found at ${ESPHOME}. Run setup.sh first." fi # Regenerate placeholder images if requested if $REGEN_IMAGES; then export ILLUSTRATIONS_DIR generate_faces fi # Check existing images if deploying with --images-only (or always before deploy) if $IMAGES_ONLY; then check_images fi # Validate only if $VALIDATE_ONLY; then cd "${ESPHOME_DIR}" for unit_name in "${UNIT_NAMES[@]}"; do config="$(unit_config "$unit_name")" if [[ -f "${config}" ]]; then log_step "Validating ${config}..." "${ESPHOME}" config "${config}" > /dev/null && log_ok "${config} valid" || log_warn "${config} invalid" fi done exit 0 fi # Deploy if $DEPLOY_ALL; then for unit_name in "${UNIT_NAMES[@]}"; do config="$(unit_config "$unit_name")" if [[ -f "${ESPHOME_DIR}/${config}" ]]; then deploy_unit "$unit_name" else log_warn "Skipping ${unit_name} — ${config} not found" fi done else deploy_unit "$TARGET" fi echo "" log_ok "Deploy complete!"