mirror of
https://github.com/lildude/ha-addon-teslamate.git
synced 2026-10-13 13:10:30 -04:00
505 lines
16 KiB
Bash
Executable File
505 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# setup-ha.sh - Idempotent Home Assistant setup for addon development
|
|
#
|
|
# This script:
|
|
# 1. Completes HA onboarding (creates admin user)
|
|
# 2. Adds the alexbelgium addon repository (for PostgreSQL)
|
|
# 3. Installs and starts PostgreSQL 17
|
|
# 4. Installs, configures, and starts the local addon
|
|
#
|
|
# Usage: bash .devcontainer/setup-ha.sh
|
|
# DEBUG=1 bash .devcontainer/setup-ha.sh # verbose output + error details
|
|
#
|
|
# Safe to run multiple times (idempotent).
|
|
|
|
set -euo pipefail
|
|
|
|
# --- Addon-specific configuration ---
|
|
ADDON_NAME="$(jq -r '.name' config.json)"
|
|
ADDON_SLUG="local_$(jq -r '.slug' config.json)"
|
|
ADDON_PORT="$(jq -r '.ports | keys[0] | split("/") | .[0]' config.json)"
|
|
ADDON_READY_LOG="TeslaMateWeb.Endpoint"
|
|
TIMEZONE="Europe/London"
|
|
|
|
# --- Home Assistant configuration ---
|
|
HA_HOST="localhost"
|
|
# Core binds port 80 under the Supervisor from 2026.8 onwards, and 8123 before
|
|
# that, so wait_for_ha resolves the port at runtime rather than assuming one.
|
|
HA_PORTS=(80 8123)
|
|
HA_URL=""
|
|
CLIENT_ID=""
|
|
ALEXBELGIUM_REPO="https://github.com/alexbelgium/hassio-addons"
|
|
ADMIN_USER="admin"
|
|
ADMIN_PASS="pass"
|
|
|
|
# --- PostgreSQL configuration ---
|
|
POSTGRES_PASSWORD="homeassistant"
|
|
POSTGRES_USER="postgres"
|
|
POSTGRES_DB="${ADDON_NAME,,}"
|
|
POSTGRES_PORT=5432
|
|
# Addon slug = repo_hash + "_" + addon_slug_from_config
|
|
# Repo hash: first 8 chars of sha1("https://github.com/alexbelgium/hassio-addons")
|
|
POSTGRES_SLUG="db21ed7f_postgres_latest"
|
|
|
|
# --- Helpers ---
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
RED='\033[0;31m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m' # No Color
|
|
|
|
log() { echo -e "${CYAN}==>${NC} ${BOLD}$*${NC}"; }
|
|
warn() { echo -e "${YELLOW}WARNING:${NC} $*" >&2; }
|
|
err() { echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; }
|
|
ok() { echo -e "${CYAN}==>${NC} ${GREEN}$*${NC}"; }
|
|
|
|
# Parse JSON, aborting with the offending body instead of a bare jq parse error
|
|
jq_or_die() {
|
|
local json="$1" filter="$2" context="$3"
|
|
if [ -z "$json" ] || ! jq empty <<< "$json" 2>/dev/null; then
|
|
err "${context}: expected JSON, got: ${json}"
|
|
fi
|
|
jq -r "$filter" <<< "$json"
|
|
}
|
|
|
|
# Run Supervisor API call via hassio_cli container
|
|
# IMPORTANT: Use single quotes for outer sh -c argument so $SUPERVISOR_TOKEN
|
|
# is expanded by the inner shell (where the env var exists), not the outer bash.
|
|
supervisor_api() {
|
|
local method="$1" endpoint="$2"
|
|
docker exec hassio_cli sh -c \
|
|
'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" http://supervisor'"${endpoint}"
|
|
}
|
|
|
|
supervisor_api_with_body() {
|
|
local method="$1" endpoint="$2" body="$3"
|
|
docker exec -e "BODY=${body}" hassio_cli sh -c \
|
|
'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" -d "$BODY" http://supervisor'"${endpoint}"
|
|
}
|
|
|
|
# Query a specific jq field from a Supervisor API response.
|
|
# Supervisor reports HTTP errors as plain text (e.g. "404: Not Found"), which is
|
|
# not JSON, so surface those and return nothing to let callers keep polling.
|
|
supervisor_api_jq() {
|
|
local endpoint="$1" jq_filter="$2"
|
|
local response
|
|
response=$(supervisor_api GET "${endpoint}")
|
|
if [ -z "$response" ] || ! jq empty <<< "$response" 2>/dev/null; then
|
|
warn "Supervisor API ${endpoint} returned non-JSON: ${response}"
|
|
return 0
|
|
fi
|
|
jq -r "$jq_filter" <<< "$response"
|
|
}
|
|
|
|
# Check if a jq expression matches (returns 0 if match found, 1 otherwise)
|
|
supervisor_api_jq_test() {
|
|
local endpoint="$1" jq_filter="$2"
|
|
docker exec -e "JQ_FILTER=${jq_filter}" hassio_cli sh -c \
|
|
'curl -s -H "Authorization: Bearer $SUPERVISOR_TOKEN" http://supervisor'"${endpoint}"' | jq -e "$JQ_FILTER" > /dev/null 2>&1'
|
|
}
|
|
|
|
# --- Step 0: Wait for HA to be ready ---
|
|
|
|
# Print an addon's log to help diagnose a failure
|
|
dump_addon_logs() {
|
|
local slug="$1"
|
|
echo "::group::${slug} logs"
|
|
supervisor_api GET "/addons/${slug}/logs" || true
|
|
echo "::endgroup::"
|
|
}
|
|
|
|
# Best-effort Home Assistant Core state from the Supervisor, for diagnostics
|
|
ha_core_state() {
|
|
local info
|
|
info=$(supervisor_api GET "/core/info" 2>/dev/null) || true
|
|
if [ -n "$info" ] && jq empty <<< "$info" 2>/dev/null; then
|
|
jq -r '.data.state // "unknown"' <<< "$info"
|
|
else
|
|
echo "unavailable"
|
|
fi
|
|
}
|
|
|
|
# HA answers on its HTTP port long before the API is usable, and from 2026.8 the
|
|
# port itself moved from 8123 to 80 under the Supervisor. Older releases still use
|
|
# 8123, and during onboarding new releases answer on both - but 8123 only serves a
|
|
# redirect that Core tears down once onboarding completes. So port 80 is tried
|
|
# first, redirects are deliberately not followed, and a port only counts as ready
|
|
# when it serves the onboarding payload itself rather than merely valid JSON.
|
|
HA_PROBE=""
|
|
|
|
ha_probe() {
|
|
local port="$1" raw status body
|
|
raw=$(curl -s --max-time 5 -w $'\n%{http_code}' "http://${HA_HOST}:${port}/api/onboarding" 2>/dev/null || true)
|
|
status=${raw##*$'\n'}
|
|
body=${raw%$'\n'*}
|
|
HA_PROBE="${HA_PROBE:+${HA_PROBE} }port=${port} http=${status:-none} body=${body:0:80}"
|
|
jq -e 'type == "array" and (.[0] | has("step"))' <<< "$body" > /dev/null 2>&1
|
|
}
|
|
|
|
# Sets HA_URL and CLIENT_ID on success. Must not run in a subshell.
|
|
ha_resolve_url() {
|
|
local port
|
|
HA_PROBE=""
|
|
for port in "${HA_PORTS[@]}"; do
|
|
if ha_probe "$port"; then
|
|
HA_URL="http://${HA_HOST}:${port}"
|
|
CLIENT_ID="${HA_URL}/"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
wait_for_ha() {
|
|
log "Waiting for Home Assistant to be ready..."
|
|
local max_attempts=120
|
|
local attempt=0
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
if ha_resolve_url; then
|
|
ok "Home Assistant is ready at ${HA_URL}"
|
|
return 0
|
|
fi
|
|
if [ $((attempt % 6)) -eq 0 ]; then
|
|
log " attempt ${attempt}/${max_attempts}: core=$(ha_core_state) ${HA_PROBE}"
|
|
fi
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
err "Home Assistant did not become ready in time (core=$(ha_core_state) ${HA_PROBE})"
|
|
}
|
|
|
|
# --- Step 1: Complete onboarding ---
|
|
|
|
complete_onboarding() {
|
|
log "Checking onboarding status..."
|
|
local onboarding
|
|
onboarding=$(curl -sfL "${HA_URL}/api/onboarding")
|
|
|
|
local user_done
|
|
user_done=$(jq_or_die "$onboarding" '.[] | select(.step == "user") | .done' "Onboarding status")
|
|
|
|
if [ "$user_done" = "true" ]; then
|
|
ok "Onboarding already completed"
|
|
return 0
|
|
fi
|
|
|
|
log "Creating admin user '${ADMIN_USER}'..."
|
|
local auth_response
|
|
auth_response=$(curl -sfL -X POST "${HA_URL}/api/onboarding/users" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"client_id\":\"${CLIENT_ID}\",\"name\":\"Admin\",\"username\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\",\"language\":\"en\"}")
|
|
|
|
local auth_code
|
|
auth_code=$(jq_or_die "$auth_response" '.auth_code' "Create admin user")
|
|
|
|
if [ -z "$auth_code" ] || [ "$auth_code" = "null" ]; then
|
|
err "Failed to create user: ${auth_response}"
|
|
fi
|
|
|
|
# Exchange auth code for access token
|
|
local token_response
|
|
token_response=$(curl -sfL -X POST "${HA_URL}/auth/token" \
|
|
--data-urlencode "grant_type=authorization_code" \
|
|
--data-urlencode "code=${auth_code}" \
|
|
--data-urlencode "client_id=${CLIENT_ID}")
|
|
|
|
local access_token
|
|
access_token=$(jq_or_die "$token_response" '.access_token' "Token exchange")
|
|
|
|
if [ -z "$access_token" ] || [ "$access_token" = "null" ]; then
|
|
err "Failed to get access token: ${token_response}"
|
|
fi
|
|
|
|
# Complete remaining onboarding steps
|
|
log "Completing onboarding steps..."
|
|
curl -sfL -X POST "${HA_URL}/api/onboarding/core_config" \
|
|
-H "Authorization: Bearer ${access_token}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{}" > /dev/null 2>&1 || true
|
|
|
|
curl -sfL -X POST "${HA_URL}/api/onboarding/analytics" \
|
|
-H "Authorization: Bearer ${access_token}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{}" > /dev/null 2>&1 || true
|
|
|
|
curl -sfL -X POST "${HA_URL}/api/onboarding/integration" \
|
|
-H "Authorization: Bearer ${access_token}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"client_id\":\"${CLIENT_ID}\",\"redirect_uri\":\"${HA_URL}/\"}" > /dev/null 2>&1 || true
|
|
|
|
ok "Onboarding complete"
|
|
}
|
|
|
|
# --- Step 2: Add alexbelgium addon repository ---
|
|
|
|
add_addon_repo() {
|
|
log "Checking addon repositories..."
|
|
|
|
if supervisor_api_jq_test "/store/repositories" ".data[] | select(.source == \"${ALEXBELGIUM_REPO}\")"; then
|
|
ok "Repository already added: ${ALEXBELGIUM_REPO}"
|
|
return 0
|
|
fi
|
|
|
|
log "Adding addon repository: ${ALEXBELGIUM_REPO}"
|
|
supervisor_api_with_body POST "/store/repositories" \
|
|
"{\"repository\":\"${ALEXBELGIUM_REPO}\"}" > /dev/null
|
|
|
|
# Wait for the store to refresh by checking if the postgres addon is available
|
|
log "Waiting for store to refresh..."
|
|
local max_attempts=60
|
|
local attempt=0
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
if supervisor_api_jq_test "/addons/${POSTGRES_SLUG}/info" '.data.name'; then
|
|
ok "Store refreshed successfully"
|
|
return 0
|
|
fi
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
err "Store did not refresh in time - postgres addon not found"
|
|
}
|
|
|
|
# --- Step 3: Install PostgreSQL ---
|
|
|
|
install_postgres() {
|
|
local postgres_slug="$1"
|
|
|
|
log "Checking PostgreSQL addon status (${postgres_slug})..."
|
|
local version
|
|
version=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.version // empty')
|
|
|
|
if [ -n "$version" ]; then
|
|
local state
|
|
state=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.state')
|
|
ok "PostgreSQL addon already installed (version: ${version}, state: ${state})"
|
|
return 0
|
|
fi
|
|
|
|
log "Installing PostgreSQL addon..."
|
|
supervisor_api POST "/addons/${postgres_slug}/install" > /dev/null
|
|
|
|
# Wait for installation to complete
|
|
log "Waiting for PostgreSQL installation..."
|
|
local max_attempts=120
|
|
local attempt=0
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
version=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.version // empty')
|
|
if [ -n "$version" ]; then
|
|
ok "PostgreSQL addon installed (version: ${version})"
|
|
return 0
|
|
fi
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
err "PostgreSQL addon installation timed out"
|
|
}
|
|
|
|
# --- Step 4: Configure and start PostgreSQL ---
|
|
|
|
configure_and_start_postgres() {
|
|
local postgres_slug="$1"
|
|
|
|
log "Configuring PostgreSQL addon..."
|
|
local options_json
|
|
options_json=$(jq -n \
|
|
--arg pw "$POSTGRES_PASSWORD" \
|
|
--arg db "$POSTGRES_DB" \
|
|
'{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, env_vars: []}}')
|
|
|
|
supervisor_api_with_body POST "/addons/${postgres_slug}/options" \
|
|
"${options_json}" > /dev/null
|
|
|
|
# Check if already running
|
|
local state
|
|
state=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.state')
|
|
|
|
if [ "$state" = "started" ]; then
|
|
ok "PostgreSQL addon already running"
|
|
return 0
|
|
fi
|
|
|
|
log "Starting PostgreSQL addon..."
|
|
supervisor_api POST "/addons/${postgres_slug}/start" > /dev/null
|
|
|
|
# Wait for postgres to be ready
|
|
log "Waiting for PostgreSQL to be ready..."
|
|
local max_attempts=60
|
|
local attempt=0
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
state=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.state')
|
|
if [ "$state" = "started" ]; then
|
|
ok "PostgreSQL addon is running"
|
|
wait_for_postgres_ready "$postgres_slug"
|
|
return 0
|
|
fi
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
err "PostgreSQL addon did not start in time"
|
|
}
|
|
|
|
wait_for_postgres_ready() {
|
|
local postgres_slug="$1"
|
|
# Supervisor renamed add-on containers from addon_* to app_*, so try both.
|
|
local containers=("app_${postgres_slug}" "addon_${postgres_slug}")
|
|
|
|
log "Waiting for PostgreSQL database to accept connections..."
|
|
local max_attempts=60
|
|
local attempt=0
|
|
local container probe
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
for container in "${containers[@]}"; do
|
|
if probe=$(docker exec -e "PGPASSWORD=${POSTGRES_PASSWORD}" "$container" \
|
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT 1" 2>&1); then
|
|
ok "PostgreSQL database is ready"
|
|
return 0
|
|
fi
|
|
done
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
warn "Last psql output: ${probe}"
|
|
dump_addon_logs "$postgres_slug"
|
|
err "PostgreSQL database did not become ready in time"
|
|
}
|
|
|
|
# --- Step 5: Install and configure addon ---
|
|
|
|
install_addon() {
|
|
log "Checking ${ADDON_NAME} addon status..."
|
|
local version
|
|
version=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.version // empty')
|
|
|
|
if [ -n "$version" ]; then
|
|
local state
|
|
state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state')
|
|
ok "${ADDON_NAME} addon already installed (version: ${version}, state: ${state})"
|
|
return 0
|
|
fi
|
|
|
|
log "Installing ${ADDON_NAME} addon..."
|
|
supervisor_api POST "/addons/${ADDON_SLUG}/install" > /dev/null
|
|
|
|
# Wait for installation to complete (the TeslaMate image is large)
|
|
log "Waiting for ${ADDON_NAME} installation..."
|
|
local max_attempts=120
|
|
local attempt=0
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
version=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.version // empty')
|
|
if [ -n "$version" ]; then
|
|
ok "${ADDON_NAME} addon installed (version: ${version})"
|
|
return 0
|
|
fi
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
err "${ADDON_NAME} addon installation timed out"
|
|
}
|
|
|
|
configure_and_start_addon() {
|
|
local postgres_slug="$1"
|
|
|
|
# Derive the postgres hostname from its slug (underscores → hyphens)
|
|
local postgres_host
|
|
postgres_host=$(echo "$postgres_slug" | tr '_' '-')
|
|
|
|
log "Configuring ${ADDON_NAME} addon (db host: ${postgres_host})..."
|
|
local current_options overrides options_json
|
|
current_options=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.options')
|
|
overrides=$(jq -n \
|
|
--arg user "$POSTGRES_USER" \
|
|
--arg pass "$POSTGRES_PASSWORD" \
|
|
--argjson port "$POSTGRES_PORT" \
|
|
--arg host "$postgres_host" \
|
|
--arg db "$POSTGRES_DB" \
|
|
--arg tz "$TIMEZONE" \
|
|
'{database_user: $user, database_pass: $pass, database_port: $port, database_host: $host, database_name: $db, database_ssl: false, disable_mqtt: true, grafana_import_dashboards: false, timezone: $tz}')
|
|
options_json=$(jq -n --argjson cur "$current_options" --argjson ovr "$overrides" '{options: ($cur + $ovr)}')
|
|
|
|
supervisor_api_with_body POST "/addons/${ADDON_SLUG}/options" \
|
|
"${options_json}" > /dev/null
|
|
|
|
# Check if already running
|
|
local state
|
|
state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state')
|
|
|
|
if [ "$state" = "started" ]; then
|
|
ok "${ADDON_NAME} addon already running"
|
|
wait_for_addon_ready
|
|
return 0
|
|
fi
|
|
|
|
log "Starting ${ADDON_NAME} addon..."
|
|
supervisor_api POST "/addons/${ADDON_SLUG}/start" > /dev/null
|
|
|
|
# Wait for addon state to become "started"
|
|
log "Waiting for ${ADDON_NAME} addon to start..."
|
|
local max_attempts=60
|
|
local attempt=0
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state')
|
|
if [ "$state" = "started" ]; then
|
|
break
|
|
fi
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
if [ "$state" != "started" ]; then
|
|
dump_addon_logs "${ADDON_SLUG}"
|
|
err "${ADDON_NAME} addon did not start in time (state: ${state})"
|
|
fi
|
|
|
|
wait_for_addon_ready
|
|
ok "${ADDON_NAME} addon started and ready"
|
|
}
|
|
|
|
wait_for_addon_ready() {
|
|
log "Waiting for ${ADDON_NAME} to be ready (listening on port ${ADDON_PORT})..."
|
|
local max_attempts=60
|
|
local attempt=0
|
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
|
local logs
|
|
logs=$(supervisor_api GET "/addons/${ADDON_SLUG}/logs" 2>/dev/null || true)
|
|
if echo "$logs" | grep -qF "$ADDON_READY_LOG"; then
|
|
ok "${ADDON_NAME} is ready"
|
|
return 0
|
|
fi
|
|
sleep 5
|
|
attempt=$((attempt + 1))
|
|
done
|
|
dump_addon_logs "${ADDON_SLUG}"
|
|
err "${ADDON_NAME} did not become ready in time (never saw '${ADDON_READY_LOG}' in logs)"
|
|
}
|
|
|
|
# --- Main ---
|
|
|
|
main() {
|
|
log "Setting up Home Assistant for ${ADDON_NAME} development"
|
|
echo ""
|
|
|
|
wait_for_ha
|
|
complete_onboarding
|
|
add_addon_repo
|
|
|
|
install_postgres "$POSTGRES_SLUG"
|
|
configure_and_start_postgres "$POSTGRES_SLUG"
|
|
install_addon
|
|
configure_and_start_addon "$POSTGRES_SLUG"
|
|
|
|
echo ""
|
|
ok "Setup complete!"
|
|
local host_port
|
|
# Mirrors the appPort list in devcontainer.json.
|
|
case "${HA_URL##*:}" in
|
|
80) host_port=7180 ;;
|
|
*) host_port=7123 ;;
|
|
esac
|
|
log " Home Assistant: ${HA_URL} (user: ${ADMIN_USER}, pass: ${ADMIN_PASS})"
|
|
log " from the host: http://localhost:${host_port}"
|
|
log " PostgreSQL: ${POSTGRES_SLUG} (user: ${POSTGRES_USER}, db: ${POSTGRES_DB})"
|
|
log " ${ADDON_NAME}: accessible via Home Assistant ingress"
|
|
}
|
|
|
|
main "$@"
|