Files
homeai/homeai-esp32/deploy.sh
Aodhan Collins c4cecbd8dc feat: ESP32-S3-BOX-3 room satellite — ESPHome config, OTA deploy, placeholder faces
Living room unit fully working: on-device wake word (hey_jarvis), voice pipeline
via HA (Wyoming STT → OpenClaw → Wyoming TTS), static PNG display states, OTA
updates. Includes deploy.sh for quick OTA with custom image support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:48:03 +00:00

245 lines
8.6 KiB
Bash
Executable File

#!/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!"