From 63af0134bf97ce72d9591758bfdf29d123134fba Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 12:21:12 +0000 Subject: [PATCH 01/26] Update devcontainer config --- .devcontainer/devcontainer.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 91aab66..2d59063 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,11 +1,15 @@ { "name": "HA Addon: TeslaMate", - "image": "ghcr.io/home-assistant/devcontainer:2-addons", + "image": "ghcr.io/home-assistant/devcontainer:5-apps", + "overrideCommand": false, "appPort": ["7123:8123", "7357:4357"], "postStartCommand": "bash devcontainer_bootstrap", "runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"], + "workspaceFolder": "/mnt/supervisor/addons/local/${localWorkspaceFolderBasename}", + "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached", "containerEnv": { - "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}" + "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}", + "SUPERVISOR_CHANNEL": "beta" }, "customizations": { "vscode": { @@ -29,6 +33,7 @@ }, "mounts": [ "type=volume,target=/var/lib/docker", + "type=volume,target=/var/lib/containerd", "type=volume,target=/mnt/supervisor" ] } \ No newline at end of file From 09248846dc46270c242bf0ddf3b8d555a009447a Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 12:21:32 +0000 Subject: [PATCH 02/26] Add script to setup dev env --- .devcontainer/setup-ha.sh | 433 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100755 .devcontainer/setup-ha.sh diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh new file mode 100755 index 0000000..adf6141 --- /dev/null +++ b/.devcontainer/setup-ha.sh @@ -0,0 +1,433 @@ +#!/usr/bin/env bash +# setup-ha.sh - Idempotent Home Assistant setup for TeslaMate 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 TeslaMate addon +# +# MQTT and Grafana dashboard import are disabled so TeslaMate boots cleanly +# against PostgreSQL alone. Enable them in the addon configuration once a +# broker / Grafana instance is available. +# +# Usage: bash .devcontainer/setup-ha.sh +# +# Safe to run multiple times (idempotent). + +set -euo pipefail + +HA_URL="http://localhost:8123" +ALEXBELGIUM_REPO="https://github.com/alexbelgium/hassio-addons" +ADMIN_USER="admin" +ADMIN_PASS="pass" +CLIENT_ID="${HA_URL}/" + +POSTGRES_PASSWORD="homeassistant" +POSTGRES_USER="postgres" +POSTGRES_DB="teslamate" +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" + +TESLAMATE_SLUG="local_teslamate" +TESLAMATE_PORT=4000 +TIMEZONE="Europe/London" + +# Path to the addon's config.json (one level up from this script's directory), +# resolved independently of the current working directory. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ADDON_DIR="$(dirname "$SCRIPT_DIR")" +CONFIG_JSON="${ADDON_DIR}/config.json" + +# --- 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}"; } + +# 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 +# Runs curl and jq inside hassio_cli to avoid docker exec stdout pipe issues +supervisor_api_jq() { + 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 -r "$JQ_FILTER"' +} + +# 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 --- + +wait_for_ha() { + log "Waiting for Home Assistant to be ready..." + local max_attempts=60 + local attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + if curl -sf "${HA_URL}/api/onboarding" > /dev/null 2>&1; then + ok "Home Assistant is ready" + return 0 + fi + sleep 5 + attempt=$((attempt + 1)) + done + err "Home Assistant did not become ready in time" +} + +# --- Step 1: Complete onboarding --- + +complete_onboarding() { + log "Checking onboarding status..." + local onboarding + onboarding=$(curl -sf "${HA_URL}/api/onboarding") + + local user_done + user_done=$(echo "$onboarding" | jq -r '.[] | select(.step == "user") | .done') + + if [ "$user_done" = "true" ]; then + ok "Onboarding already completed" + return 0 + fi + + log "Creating admin user '${ADMIN_USER}'..." + local auth_response + auth_response=$(curl -sf -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=$(echo "$auth_response" | jq -r '.auth_code') + + 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 -sf -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=$(echo "$token_response" | jq -r '.access_token') + + 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 -sf -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 -sf -X POST "${HA_URL}/api/onboarding/analytics" \ + -H "Authorization: Bearer ${access_token}" \ + -H "Content-Type: application/json" \ + -d "{}" > /dev/null 2>&1 || true + + curl -sf -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" + # Give postgres time to initialize the database + sleep 10 + return 0 + fi + sleep 5 + attempt=$((attempt + 1)) + done + err "PostgreSQL addon did not start in time" +} + +# --- Step 5: Install and configure TeslaMate --- + +# Remove the "image" key from config.json so the Supervisor builds the addon +# image locally from the Dockerfile instead of pulling a pre-built image. +remove_addon_image() { + log "Ensuring TeslaMate addon builds locally..." + + if [ ! -f "$CONFIG_JSON" ]; then + err "config.json not found at ${CONFIG_JSON}" + fi + + if ! jq -e 'has("image")' "$CONFIG_JSON" > /dev/null 2>&1; then + ok "config.json already has no 'image' key - addon will build locally" + return 0 + fi + + jq 'del(.image)' "$CONFIG_JSON" > "${CONFIG_JSON}.tmp" && mv "${CONFIG_JSON}.tmp" "$CONFIG_JSON" + ok "Removed 'image' key from config.json" + + # Reload the store so the Supervisor re-reads the local addon config + log "Reloading addon store..." + supervisor_api POST "/store/reload" > /dev/null +} + +install_teslamate() { + log "Checking TeslaMate addon status..." + local version + version=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.version // empty') + + if [ -n "$version" ]; then + local state + state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') + ok "TeslaMate addon already installed (version: ${version}, state: ${state})" + return 0 + fi + + log "Installing TeslaMate addon..." + supervisor_api POST "/addons/${TESLAMATE_SLUG}/install" > /dev/null + + # Wait for installation to complete (the TeslaMate image is large) + log "Waiting for TeslaMate installation..." + local max_attempts=120 + local attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + version=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.version // empty') + if [ -n "$version" ]; then + ok "TeslaMate addon installed (version: ${version})" + return 0 + fi + sleep 5 + attempt=$((attempt + 1)) + done + err "TeslaMate addon installation timed out" +} + +configure_and_start_teslamate() { + local postgres_slug="$1" + + # Derive the postgres hostname from its slug (underscores -> hyphens) + local postgres_host + postgres_host=$(echo "$postgres_slug" | tr '_' '-') + + log "Configuring TeslaMate addon (db host: ${postgres_host})..." + + # Fetch the addon's current options - already populated with defaults - and overlay only + # the values we need to change. + local current_options overrides options_json + current_options=$(supervisor_api_jq "/addons/${TESLAMATE_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)}') + + local response + response=$(supervisor_api_with_body POST "/addons/${TESLAMATE_SLUG}/options" "${options_json}") + if [ "$(echo "$response" | jq -r '.result')" != "ok" ]; then + err "Failed to set TeslaMate options: $(echo "$response" | jq -r '.message // .')" + fi + + # Check if already running + local state + state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') + + if [ "$state" = "started" ]; then + ok "TeslaMate addon already running" + wait_for_teslamate_ready + return 0 + fi + + log "Starting TeslaMate addon..." + supervisor_api POST "/addons/${TESLAMATE_SLUG}/start" > /dev/null + + # Wait for addon state to become "started" + log "Waiting for TeslaMate addon to start..." + local max_attempts=60 + local attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') + if [ "$state" = "started" ]; then + break + fi + sleep 5 + attempt=$((attempt + 1)) + done + if [ "$state" != "started" ]; then + err "TeslaMate addon did not start in time (state: ${state})" + fi + + wait_for_teslamate_ready + ok "TeslaMate addon started and ready" +} + +wait_for_teslamate_ready() { + log "Waiting for TeslaMate to be ready (listening on port ${TESLAMATE_PORT})..." + local max_attempts=60 + local attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + local logs + logs=$(supervisor_api GET "/addons/${TESLAMATE_SLUG}/logs" 2>/dev/null || true) + if echo "$logs" | grep -q "TeslaMateWeb.Endpoint"; then + ok "TeslaMate is ready" + return 0 + fi + sleep 5 + attempt=$((attempt + 1)) + done + err "TeslaMate did not become ready in time (never saw 'TeslaMateWeb.Endpoint' in logs)" +} + +# --- Main --- + +main() { + log "Setting up Home Assistant for TeslaMate development" + echo "" + + wait_for_ha + complete_onboarding + add_addon_repo + + install_postgres "$POSTGRES_SLUG" + configure_and_start_postgres "$POSTGRES_SLUG" + remove_addon_image + install_teslamate + configure_and_start_teslamate "$POSTGRES_SLUG" + + echo "" + ok "Setup complete!" + log " Home Assistant: ${HA_URL} (user: ${ADMIN_USER}, pass: ${ADMIN_PASS})" + log " PostgreSQL: ${POSTGRES_SLUG} (user: ${POSTGRES_USER}, db: ${POSTGRES_DB})" + log " TeslaMate: open the TeslaMate panel via HA ingress (${HA_URL})" +} + +main "$@" From 395ee9bd4a765adbe0241f32b22c63f9f1e9843b Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 12:21:45 +0000 Subject: [PATCH 03/26] Add integration test --- .github/workflows/integration-test.yaml | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/integration-test.yaml diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml new file mode 100644 index 0000000..b5397fd --- /dev/null +++ b/.github/workflows/integration-test.yaml @@ -0,0 +1,43 @@ +name: Integration Test + +on: + push: + branches: + - main + paths: + - build.json + - config.json + - Dockerfile + - rootfs/** + - .devcontainer/** + pull_request: + branches: + - main + paths: + - build.json + - config.json + - Dockerfile + - rootfs/** + - .devcontainer/** + +jobs: + integration: + name: Integration test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v6.0.2 + + - name: Run integration test in devcontainer + uses: devcontainers/ci@v0.3 + with: + runCmd: | + printf '#!/bin/sh\nexit 0\n' | sudo tee /usr/sbin/apparmor_parser > /dev/null && sudo chmod +x /usr/sbin/apparmor_parser + sudo sed -i 's|docker run --rm --privileged|docker run --rm --privileged -v /usr/sbin/apparmor_parser:/usr/sbin/apparmor_parser:ro|' /usr/bin/supervisor_run + if [ -n "${DEBUG:-}" ]; then + supervisor_run & + else + supervisor_run > /dev/null 2>&1 & + fi + bash .devcontainer/setup-ha.sh From 6d11bf974b981e222a9ae5ab4e923ae3b7095d95 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 12:36:59 +0000 Subject: [PATCH 04/26] Try using new env var Use this instead of manually manipulating the script --- .github/workflows/integration-test.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index b5397fd..320718c 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -33,11 +33,9 @@ jobs: uses: devcontainers/ci@v0.3 with: runCmd: | - printf '#!/bin/sh\nexit 0\n' | sudo tee /usr/sbin/apparmor_parser > /dev/null && sudo chmod +x /usr/sbin/apparmor_parser - sudo sed -i 's|docker run --rm --privileged|docker run --rm --privileged -v /usr/sbin/apparmor_parser:/usr/sbin/apparmor_parser:ro|' /usr/bin/supervisor_run if [ -n "${DEBUG:-}" ]; then - supervisor_run & + SUPERVISOR_UNCONFINED=1 supervisor_run & else - supervisor_run > /dev/null 2>&1 & + SUPERVISOR_UNCONFINED=1 supervisor_run > /dev/null 2>&1 & fi bash .devcontainer/setup-ha.sh From 1f3345f01ebce567a2b0edbd7876337acadc8330 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 12:49:27 +0000 Subject: [PATCH 05/26] Add debug info --- .devcontainer/setup-ha.sh | 96 +++++++++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index adf6141..f2d3687 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -12,11 +12,21 @@ # broker / Grafana instance is available. # # 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 +# DEBUG: when set to a truthy value (1/true/yes/on), print verbose diagnostics +# and surface API responses/errors that are normally hidden. Unset (or 0/false) +# keeps the default, quiet output. +DEBUG="${DEBUG:-0}" +case "${DEBUG,,}" in + 1 | true | yes | on) DEBUG=1 ;; + *) DEBUG=0 ;; +esac + HA_URL="http://localhost:8123" ALEXBELGIUM_REPO="https://github.com/alexbelgium/hassio-addons" ADMIN_USER="admin" @@ -47,6 +57,7 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' CYAN='\033[0;36m' +DIM='\033[2m' BOLD='\033[1m' NC='\033[0m' # No Color @@ -54,20 +65,62 @@ 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}"; } +# debug() prints only when DEBUG is enabled and writes to stderr, so it never +# pollutes function output captured via $(...). +debug() { + [ "$DEBUG" = "1" ] || return 0 + echo -e "${DIM}DEBUG:${NC} $*" >&2 +} +# debug_response() logs an API response when debugging, truncated so large +# payloads (e.g. addon logs) stay readable. +debug_response() { + [ "$DEBUG" = "1" ] || return 0 + local resp="$1" max=800 + if [ "${#resp}" -gt "$max" ]; then + debug "response (truncated): ${resp:0:$max}..." + else + debug "response: ${resp}" + fi +} # 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}" + debug "API ${method} ${endpoint}" + local resp + resp=$(docker exec hassio_cli sh -c \ + 'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" http://supervisor'"${endpoint}") + debug_response "$resp" + printf '%s\n' "$resp" } 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}" + debug "API ${method} ${endpoint} body=${body}" + local resp + resp=$(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}") + debug_response "$resp" + printf '%s\n' "$resp" +} + +# POST to the local HA API for an optional onboarding step. Failures are +# non-fatal; the request and any response/error are shown only when debugging. +ha_post_optional() { + local token="$1" endpoint="$2" data="$3" + debug "POST ${endpoint} ${data}" + local resp rc=0 + resp=$(curl -sS -X POST "${HA_URL}${endpoint}" \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + -d "$data" 2>&1) || rc=$? + if [ "$rc" -ne 0 ]; then + debug "${endpoint} failed (rc=${rc}): ${resp}" + elif [ -n "$resp" ]; then + debug "${endpoint} response: ${resp}" + fi } # Query a specific jq field from a Supervisor API response @@ -96,6 +149,7 @@ wait_for_ha() { ok "Home Assistant is ready" return 0 fi + debug "Attempt $((attempt + 1))/${max_attempts}: Home Assistant not ready yet" sleep 5 attempt=$((attempt + 1)) done @@ -122,6 +176,7 @@ complete_onboarding() { auth_response=$(curl -sf -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\"}") + debug "users response: ${auth_response}" local auth_code auth_code=$(echo "$auth_response" | jq -r '.auth_code') @@ -136,6 +191,7 @@ complete_onboarding() { --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=${auth_code}" \ --data-urlencode "client_id=${CLIENT_ID}") + debug "token response: ${token_response}" local access_token access_token=$(echo "$token_response" | jq -r '.access_token') @@ -144,22 +200,12 @@ complete_onboarding() { err "Failed to get access token: ${token_response}" fi - # Complete remaining onboarding steps + # Complete remaining onboarding steps (best-effort; failures are non-fatal) log "Completing onboarding steps..." - curl -sf -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 -sf -X POST "${HA_URL}/api/onboarding/analytics" \ - -H "Authorization: Bearer ${access_token}" \ - -H "Content-Type: application/json" \ - -d "{}" > /dev/null 2>&1 || true - - curl -sf -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 + ha_post_optional "$access_token" "/api/onboarding/core_config" "{}" + ha_post_optional "$access_token" "/api/onboarding/analytics" "{}" + ha_post_optional "$access_token" "/api/onboarding/integration" \ + "{\"client_id\":\"${CLIENT_ID}\",\"redirect_uri\":\"${HA_URL}/\"}" ok "Onboarding complete" } @@ -187,6 +233,7 @@ add_addon_repo() { ok "Store refreshed successfully" return 0 fi + debug "Attempt $((attempt + 1))/${max_attempts}: postgres addon not in store yet" sleep 5 attempt=$((attempt + 1)) done @@ -222,6 +269,7 @@ install_postgres() { ok "PostgreSQL addon installed (version: ${version})" return 0 fi + debug "Attempt $((attempt + 1))/${max_attempts}: PostgreSQL not installed yet" sleep 5 attempt=$((attempt + 1)) done @@ -267,6 +315,7 @@ configure_and_start_postgres() { sleep 10 return 0 fi + debug "Attempt $((attempt + 1))/${max_attempts}: PostgreSQL state=${state}" sleep 5 attempt=$((attempt + 1)) done @@ -322,6 +371,7 @@ install_teslamate() { ok "TeslaMate addon installed (version: ${version})" return 0 fi + debug "Attempt $((attempt + 1))/${max_attempts}: TeslaMate not installed yet" sleep 5 attempt=$((attempt + 1)) done @@ -350,6 +400,7 @@ configure_and_start_teslamate() { --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)}') + debug "options payload: ${options_json}" local response response=$(supervisor_api_with_body POST "/addons/${TESLAMATE_SLUG}/options" "${options_json}") @@ -379,6 +430,7 @@ configure_and_start_teslamate() { if [ "$state" = "started" ]; then break fi + debug "Attempt $((attempt + 1))/${max_attempts}: TeslaMate state=${state}" sleep 5 attempt=$((attempt + 1)) done @@ -401,6 +453,9 @@ wait_for_teslamate_ready() { ok "TeslaMate is ready" return 0 fi + if [ "$DEBUG" = "1" ]; then + debug "Attempt $((attempt + 1))/${max_attempts}: endpoint not up yet; last log: $(echo "$logs" | tail -n 1)" + fi sleep 5 attempt=$((attempt + 1)) done @@ -413,6 +468,9 @@ main() { log "Setting up Home Assistant for TeslaMate development" echo "" + debug "Verbose debug output enabled" + debug "HA_URL=${HA_URL} POSTGRES_SLUG=${POSTGRES_SLUG} TESLAMATE_SLUG=${TESLAMATE_SLUG}" + wait_for_ha complete_onboarding add_addon_repo From 79f52c8c925ae3861ae6fe3ad9feebb86137ab92 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 12:52:56 +0000 Subject: [PATCH 06/26] Don't hard fail if config.json missing --- .devcontainer/setup-ha.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index adf6141..eab84c5 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -281,7 +281,8 @@ remove_addon_image() { log "Ensuring TeslaMate addon builds locally..." if [ ! -f "$CONFIG_JSON" ]; then - err "config.json not found at ${CONFIG_JSON}" + warn "config.json not found at ${CONFIG_JSON} - skipping image removal" + return 0 fi if ! jq -e 'has("image")' "$CONFIG_JSON" > /dev/null 2>&1; then From be1329ae05be8c3b837d4fe42e3a9a84036d20b5 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 15:21:52 +0000 Subject: [PATCH 07/26] Better handle onboarded instance --- .devcontainer/setup-ha.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index 5962db1..c8ea38d 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -144,8 +144,12 @@ wait_for_ha() { log "Waiting for Home Assistant to be ready..." local max_attempts=60 local attempt=0 + local http_code while [ "$attempt" -lt "$max_attempts" ]; do - if curl -sf "${HA_URL}/api/onboarding" > /dev/null 2>&1; then + # 200 = onboarding pending, 404 = already onboarded; both mean HA's HTTP + # server is up and serving. A connection failure yields 000. + http_code=$(curl -s -o /dev/null -w "%{http_code}" "${HA_URL}/api/onboarding" 2>/dev/null || true) + if [ "$http_code" = "200" ] || [ "$http_code" = "404" ]; then ok "Home Assistant is ready" return 0 fi @@ -160,8 +164,18 @@ wait_for_ha() { complete_onboarding() { log "Checking onboarding status..." - local onboarding - onboarding=$(curl -sf "${HA_URL}/api/onboarding") + # Capture the body and HTTP status separately. A configured instance returns + # 404 here because the onboarding endpoints are removed once onboarding + # completes, so don't use curl -f (which would abort under set -e). + local onboarding http_code + onboarding=$(curl -s -w $'\n%{http_code}' "${HA_URL}/api/onboarding") + http_code=$(printf '%s' "$onboarding" | tail -n1) + onboarding=$(printf '%s' "$onboarding" | sed '$d') + + if [ "$http_code" = "404" ]; then + ok "Onboarding already completed (endpoint returned 404)" + return 0 + fi local user_done user_done=$(echo "$onboarding" | jq -r '.[] | select(.step == "user") | .done') From a32321ac091ed5b0d65bd797115d4a017bbd8a06 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 15:29:59 +0000 Subject: [PATCH 08/26] Add folding and debug --- .github/workflows/integration-test.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 320718c..1b4df69 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -33,9 +33,11 @@ jobs: uses: devcontainers/ci@v0.3 with: runCmd: | + echo "::group::Start Supervisor" if [ -n "${DEBUG:-}" ]; then SUPERVISOR_UNCONFINED=1 supervisor_run & else SUPERVISOR_UNCONFINED=1 supervisor_run > /dev/null 2>&1 & fi - bash .devcontainer/setup-ha.sh + echo "::endgroup::" + DEBUG=1 bash .devcontainer/setup-ha.sh From 33ce6c31dc6595da292818d4ea9d301545c5f7ad Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 15:32:36 +0000 Subject: [PATCH 09/26] Add folding --- .devcontainer/setup-ha.sh | 44 +++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index c8ea38d..3d78d9c 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -63,7 +63,7 @@ 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; } +err() { group_end; echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; } ok() { echo -e "${CYAN}==>${NC} ${GREEN}$*${NC}"; } # debug() prints only when DEBUG is enabled and writes to stderr, so it never # pollutes function output captured via $(...). @@ -83,6 +83,32 @@ debug_response() { fi } +# group_start()/group_end() wrap verbose output in GitHub Actions log groups so +# it is folded by default. They are no-ops unless DEBUG is enabled and the +# script is running inside GitHub Actions, so local and non-debug runs are +# unchanged. Markers go to stderr to stay ordered with debug() and err() output. +_group_open=0 +group_start() { + [ "$DEBUG" = "1" ] && [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 + echo "::group::$*" >&2 + _group_open=1 +} +group_end() { + [ "$_group_open" = "1" ] || return 0 + echo "::endgroup::" >&2 + _group_open=0 +} + +# run_step() runs a setup step, wrapping its output in a titled GitHub Actions +# log group (when enabled) so the verbose output folds under that heading. +run_step() { + local title="$1" + shift + group_start "$title" + "$@" + group_end +} + # 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. @@ -486,15 +512,15 @@ main() { debug "Verbose debug output enabled" debug "HA_URL=${HA_URL} POSTGRES_SLUG=${POSTGRES_SLUG} TESLAMATE_SLUG=${TESLAMATE_SLUG}" - wait_for_ha - complete_onboarding - add_addon_repo + run_step "Wait for Home Assistant" wait_for_ha + run_step "Complete onboarding" complete_onboarding + run_step "Add addon repository" add_addon_repo - install_postgres "$POSTGRES_SLUG" - configure_and_start_postgres "$POSTGRES_SLUG" - remove_addon_image - install_teslamate - configure_and_start_teslamate "$POSTGRES_SLUG" + run_step "Install PostgreSQL" install_postgres "$POSTGRES_SLUG" + run_step "Configure and start PostgreSQL" configure_and_start_postgres "$POSTGRES_SLUG" + run_step "Prepare TeslaMate local build" remove_addon_image + run_step "Install TeslaMate" install_teslamate + run_step "Configure and start TeslaMate" configure_and_start_teslamate "$POSTGRES_SLUG" echo "" ok "Setup complete!" From 6e2c577bfe9f5655376ea692c0ddfff08bc5505e Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 15:41:33 +0000 Subject: [PATCH 10/26] Pull in env var from config --- .github/workflows/integration-test.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 1b4df69..1b420f2 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -32,6 +32,9 @@ jobs: - name: Run integration test in devcontainer uses: devcontainers/ci@v0.3 with: + env: | + DEBUG=${{ vars.DEBUG }} + GITHUB_ACTIONS=true runCmd: | echo "::group::Start Supervisor" if [ -n "${DEBUG:-}" ]; then @@ -40,4 +43,4 @@ jobs: SUPERVISOR_UNCONFINED=1 supervisor_run > /dev/null 2>&1 & fi echo "::endgroup::" - DEBUG=1 bash .devcontainer/setup-ha.sh + bash .devcontainer/setup-ha.sh From 632aa56a3c450e2f20e8bb5ed4f75f3b6c10539b Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 15:59:01 +0000 Subject: [PATCH 11/26] Improve --- .devcontainer/setup-ha.sh | 82 +++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index 3d78d9c..6d24155 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -18,15 +18,6 @@ set -euo pipefail -# DEBUG: when set to a truthy value (1/true/yes/on), print verbose diagnostics -# and surface API responses/errors that are normally hidden. Unset (or 0/false) -# keeps the default, quiet output. -DEBUG="${DEBUG:-0}" -case "${DEBUG,,}" in - 1 | true | yes | on) DEBUG=1 ;; - *) DEBUG=0 ;; -esac - HA_URL="http://localhost:8123" ALEXBELGIUM_REPO="https://github.com/alexbelgium/hassio-addons" ADMIN_USER="admin" @@ -65,16 +56,15 @@ log() { echo -e "${CYAN}==>${NC} ${BOLD}$*${NC}"; } warn() { echo -e "${YELLOW}WARNING:${NC} $*" >&2; } err() { group_end; echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; } ok() { echo -e "${CYAN}==>${NC} ${GREEN}$*${NC}"; } -# debug() prints only when DEBUG is enabled and writes to stderr, so it never -# pollutes function output captured via $(...). + debug() { - [ "$DEBUG" = "1" ] || return 0 + [ -n "$DEBUG" ] || return 0 echo -e "${DIM}DEBUG:${NC} $*" >&2 } -# debug_response() logs an API response when debugging, truncated so large -# payloads (e.g. addon logs) stay readable. + + debug_response() { - [ "$DEBUG" = "1" ] || return 0 + [ -n "$DEBUG" ] || return 0 local resp="$1" max=800 if [ "${#resp}" -gt "$max" ]; then debug "response (truncated): ${resp:0:$max}..." @@ -89,7 +79,7 @@ debug_response() { # unchanged. Markers go to stderr to stay ordered with debug() and err() output. _group_open=0 group_start() { - [ "$DEBUG" = "1" ] && [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 + [ -n "$DEBUG" ] && [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 echo "::group::$*" >&2 _group_open=1 } @@ -132,6 +122,26 @@ supervisor_api_with_body() { printf '%s\n' "$resp" } +# supervisor_api_post() performs a state-changing POST (optionally with a JSON +# body) and aborts via err() if the Supervisor returns {"result":"error",...}. +# These endpoints (install/start/options/reload) are synchronous, so without +# this check a failure is silently swallowed and the script then polls for a +# state that will never arrive. The response is emitted on stdout on success. +supervisor_api_post() { + local endpoint="$1" body="${2:-}" + local resp result + if [ -n "$body" ]; then + resp=$(supervisor_api_with_body POST "$endpoint" "$body") + else + resp=$(supervisor_api POST "$endpoint") + fi + result=$(printf '%s' "$resp" | jq -r '.result // empty' 2>/dev/null) + if [ "$result" = "error" ]; then + err "Supervisor API error on POST ${endpoint}: $(printf '%s' "$resp" | jq -r '.message // .' 2>/dev/null)" + fi + printf '%s\n' "$resp" +} + # POST to the local HA API for an optional onboarding step. Failures are # non-fatal; the request and any response/error are shown only when debugging. ha_post_optional() { @@ -261,7 +271,7 @@ add_addon_repo() { fi log "Adding addon repository: ${ALEXBELGIUM_REPO}" - supervisor_api_with_body POST "/store/repositories" \ + supervisor_api_post "/store/repositories" \ "{\"repository\":\"${ALEXBELGIUM_REPO}\"}" > /dev/null # Wait for the store to refresh by checking if the postgres addon is available @@ -297,7 +307,7 @@ install_postgres() { fi log "Installing PostgreSQL addon..." - supervisor_api POST "/addons/${postgres_slug}/install" > /dev/null + supervisor_api_post "/addons/${postgres_slug}/install" > /dev/null # Wait for installation to complete log "Waiting for PostgreSQL installation..." @@ -328,7 +338,7 @@ configure_and_start_postgres() { --arg db "$POSTGRES_DB" \ '{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, env_vars: []}}') - supervisor_api_with_body POST "/addons/${postgres_slug}/options" \ + supervisor_api_post "/addons/${postgres_slug}/options" \ "${options_json}" > /dev/null # Check if already running @@ -341,7 +351,7 @@ configure_and_start_postgres() { fi log "Starting PostgreSQL addon..." - supervisor_api POST "/addons/${postgres_slug}/start" > /dev/null + supervisor_api_post "/addons/${postgres_slug}/start" > /dev/null # Wait for postgres to be ready log "Waiting for PostgreSQL to be ready..." @@ -382,9 +392,27 @@ remove_addon_image() { jq 'del(.image)' "$CONFIG_JSON" > "${CONFIG_JSON}.tmp" && mv "${CONFIG_JSON}.tmp" "$CONFIG_JSON" ok "Removed 'image' key from config.json" - # Reload the store so the Supervisor re-reads the local addon config + # Reload the store so the Supervisor re-reads the local addon config, then + # confirm the store entry actually has no image before installing. The reload + # is eventually-consistent: if we install while the store still has the + # prebuilt image set, the Supervisor tries to PULL ghcr.io/...-{arch}:dev + # (which is never published) and fails with addon_unknown_error. log "Reloading addon store..." - supervisor_api POST "/store/reload" > /dev/null + supervisor_api_post "/store/reload" > /dev/null + + log "Verifying TeslaMate will build locally..." + local attempt=0 image + while [ "$attempt" -lt 12 ]; do + image=$(supervisor_api_jq "/store/addons/${TESLAMATE_SLUG}" '.data.image // empty') + if [ -z "$image" ]; then + ok "TeslaMate store entry has no image - will build from Dockerfile" + return 0 + fi + debug "Attempt $((attempt + 1))/12: store still reports image=${image}" + sleep 5 + attempt=$((attempt + 1)) + done + err "Store still reports a prebuilt image for TeslaMate after reload; aborting to avoid pulling a non-existent image" } install_teslamate() { @@ -400,7 +428,7 @@ install_teslamate() { fi log "Installing TeslaMate addon..." - supervisor_api POST "/addons/${TESLAMATE_SLUG}/install" > /dev/null + supervisor_api_post "/addons/${TESLAMATE_SLUG}/install" > /dev/null # Wait for installation to complete (the TeslaMate image is large) log "Waiting for TeslaMate installation..." @@ -443,11 +471,7 @@ configure_and_start_teslamate() { options_json=$(jq -n --argjson cur "$current_options" --argjson ovr "$overrides" '{options: ($cur + $ovr)}') debug "options payload: ${options_json}" - local response - response=$(supervisor_api_with_body POST "/addons/${TESLAMATE_SLUG}/options" "${options_json}") - if [ "$(echo "$response" | jq -r '.result')" != "ok" ]; then - err "Failed to set TeslaMate options: $(echo "$response" | jq -r '.message // .')" - fi + supervisor_api_post "/addons/${TESLAMATE_SLUG}/options" "${options_json}" > /dev/null # Check if already running local state @@ -460,7 +484,7 @@ configure_and_start_teslamate() { fi log "Starting TeslaMate addon..." - supervisor_api POST "/addons/${TESLAMATE_SLUG}/start" > /dev/null + supervisor_api_post "/addons/${TESLAMATE_SLUG}/start" > /dev/null # Wait for addon state to become "started" log "Waiting for TeslaMate addon to start..." From 2918fd299bf8e449d1d1d0e0747d78f172199ed1 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 16:12:21 +0000 Subject: [PATCH 12/26] Handle new and old locations --- .devcontainer/setup-ha.sh | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index 6d24155..5fcfcd5 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -36,10 +36,21 @@ TESLAMATE_SLUG="local_teslamate" TESLAMATE_PORT=4000 TIMEZONE="Europe/London" -# Path to the addon's config.json (one level up from this script's directory), -# resolved independently of the current working directory. +# Path to the addon's config.json. Home Assistant mounts the local add-on store +# at /mnt/supervisor/addons/local on older Supervisor versions and at +# /mnt/supervisor/apps/local on newer ones. Derive the addon's directory name +# from the script's own location (so it is correct regardless of where the +# script is invoked from), then anchor ADDON_DIR to whichever canonical +# Supervisor path actually exists - that is the config.json the Supervisor reads. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ADDON_DIR="$(dirname "$SCRIPT_DIR")" +ADDON_NAME="$(basename "$ADDON_DIR")" +for _local_base in /mnt/supervisor/addons/local /mnt/supervisor/apps/local; do + if [ -d "${_local_base}/${ADDON_NAME}" ]; then + ADDON_DIR="${_local_base}/${ADDON_NAME}" + break + fi +done CONFIG_JSON="${ADDON_DIR}/config.json" # --- Helpers --- From 322b599e63f1024093fca782e8ce0415639de35f Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 16:34:31 +0000 Subject: [PATCH 13/26] Strip image before starting supervisor --- .github/workflows/integration-test.yaml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 1b420f2..60a8b4f 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -36,11 +36,24 @@ jobs: DEBUG=${{ vars.DEBUG }} GITHUB_ACTIONS=true runCmd: | - echo "::group::Start Supervisor" + echo "::group::Build add-on locally" >&2 + # The committed config.json carries an "image" key (required by the + # release/publish pipeline) plus "version": "dev". Left in place, the + # Supervisor pulls ghcr.io/...-{arch}:dev - a tag that is never + # published - and the integration test fails. Strip the key BEFORE + # starting Supervisor so its first store scan builds from the local + # Dockerfile; stripping later is unreliable because /store/reload + # does not re-read an already-scanned local add-on's image field. + if jq -e 'has("image")' config.json >/dev/null 2>&1; then + jq 'del(.image)' config.json > config.json.tmp && mv config.json.tmp config.json + echo "Removed 'image' key from config.json - add-on will build locally" >&2 + fi + echo "::endgroup::" >&2 + echo "::group::Start Supervisor" >&2 if [ -n "${DEBUG:-}" ]; then SUPERVISOR_UNCONFINED=1 supervisor_run & else SUPERVISOR_UNCONFINED=1 supervisor_run > /dev/null 2>&1 & fi - echo "::endgroup::" + echo "::endgroup::" >&2 bash .devcontainer/setup-ha.sh From 4c9e18d9755c8e02a01f3f9d30a8dec261407c95 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 16:54:37 +0000 Subject: [PATCH 14/26] Move away from legacy build.json --- .github/renovate.json | 5 ++--- .github/workflows/integration-test.yaml | 2 -- .vscode/settings.json | 6 ------ Dockerfile | 17 ++++++++++++----- README.md | 2 -- build.json | 18 ------------------ 6 files changed, 14 insertions(+), 36 deletions(-) delete mode 100644 build.json diff --git a/.github/renovate.json b/.github/renovate.json index f9636b3..7534483 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -12,10 +12,9 @@ "customManagers": [ { "customType": "regex", - "fileMatch": "^build.json$", + "fileMatch": "(^|/)Dockerfile$", "matchStrings": [ - "\"teslamate/teslamate:(?\\d+\\.\\d+\\.\\d+)\"", - "\"teslamate_version\": \"(?\\d+\\.\\d+\\.\\d+)\"" + "ARG teslamate_version=(?\\d+\\.\\d+\\.\\d+)" ], "datasourceTemplate": "github-releases", "depNameTemplate": "teslamate-org/teslamate" diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 60a8b4f..c539bf8 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -5,7 +5,6 @@ on: branches: - main paths: - - build.json - config.json - Dockerfile - rootfs/** @@ -14,7 +13,6 @@ on: branches: - main paths: - - build.json - config.json - Dockerfile - rootfs/** diff --git a/.vscode/settings.json b/.vscode/settings.json index 84f5d51..dab9903 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,5 @@ { "json.schemas": [ - { - "fileMatch": [ - "**/build.json" - ], - "url": "https://raw.githubusercontent.com/frenck/action-addon-linter/main/src/build.schema.json" - }, { "fileMatch": [ "**/config.json" diff --git a/Dockerfile b/Dockerfile index 0bdd423..e8acb9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,9 @@ -ARG BUILD_FROM -ARG teslamate_version -FROM teslamate/grafana:${teslamate_version} as grafana +ARG teslamate_version=3.0.0 +FROM teslamate/grafana:${teslamate_version} AS grafana #--- -ARG BUILD_FROM -FROM $BUILD_FROM +FROM teslamate/teslamate:${teslamate_version} ARG BUILD_ARCH ARG BASHIO_VERSION=0.16.2 @@ -49,5 +47,14 @@ COPY rootfs / COPY --from=grafana /dashboards /dashboards COPY --from=grafana /dashboards_internal /dashboards +LABEL \ + maintainer="Colin Seymour (https://github.com/lildude)" \ + org.opencontainers.image.authors="Colin Seymour (https://github.com/lildude)" \ + org.opencontainers.image.description="A self-hosted data logger for your Tesla." \ + org.opencontainers.image.documentation="https://github.com/lildude/ha-addon-teslamate/blob/main/DOCS.md" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.source="https://github.com/lildude/ha-addon-teslamate/" \ + org.opencontainers.image.title="Home Assistant Add-on: TeslaMate" + # S6-Overlay ENTRYPOINT ["/init"] diff --git a/README.md b/README.md index 50947cb..1a2a8e4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ Thanks to @matt-FFFFFF for maintaining this add-on in the past. [![Sponsor me to maintain this add-on][sponsor-badge]](https://github.com/sponsors/lildude) -![TeslaMate Version][teslamate-version] ![Ingress][ingres-badge] ![Supported Architectures][archs] @@ -85,6 +84,5 @@ Everything should pick up where it was before. [repo-btn]: https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg [sponsor-badge]: https://img.shields.io/badge/Sponsor_Me-%E2%9D%A4-ec6cb9?logo=GitHub [teslamate-backup]: https://docs.teslamate.org/docs/maintenance/backup_restore -[teslamate-version]: https://img.shields.io/badge/dynamic/json?label=TeslaMate%20Version&url=https%3A%2F%2Fraw.githubusercontent.com%2Flildude%2Fha-addon-teslamate%2Fmain%2Fbuild.json&query=%24.args.teslamate_version [teslamate]: https://github.com/teslamate-org/teslamate/ [timescaledb]: https://github.com/expaso/hassos-addon-timescaledb diff --git a/build.json b/build.json deleted file mode 100644 index a84eb05..0000000 --- a/build.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "build_from": { - "aarch64": "teslamate/teslamate:3.0.0", - "amd64": "teslamate/teslamate:3.0.0" - }, - "args": { - "teslamate_version": "3.0.0" - }, - "labels":{ - "maintainer": "Colin Seymour (https://github.com/lildude)", - "org.opencontainers.image.authors": "Colin Seymour (https://github.com/lildude)", - "org.opencontainers.image.description": "A self-hosted data logger for your Tesla.", - "org.opencontainers.image.documentation": "https://github.com/lildude/ha-addon-teslamate/blob/main/DOCS.md", - "org.opencontainers.image.licenses": "MIT", - "org.opencontainers.image.source": "https://github.com/lildude/ha-addon-teslamate/", - "org.opencontainers.image.title": "Home Assistant Add-on: TeslaMate" - } -} \ No newline at end of file From 4f844af7727a823811331400a3b70a070d164a85 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 14 Jun 2026 17:02:21 +0000 Subject: [PATCH 15/26] Update to newest build system --- .github/workflows/publisher.yaml | 79 +++++++++++++++++++++++--------- .github/workflows/test.yaml | 68 ++++++++++++++++++++------- 2 files changed, 108 insertions(+), 39 deletions(-) diff --git a/.github/workflows/publisher.yaml b/.github/workflows/publisher.yaml index 58cb199..ba6b207 100644 --- a/.github/workflows/publisher.yaml +++ b/.github/workflows/publisher.yaml @@ -11,41 +11,76 @@ on: type: string jobs: - publish: - name: Publish + init: + name: Initialize build runs-on: ubuntu-latest permissions: contents: read - packages: write - strategy: - matrix: - arch: ["amd64", "aarch64"] - + outputs: + version: ${{ steps.version.outputs.version }} + matrix: ${{ steps.matrix.outputs.matrix }} steps: - name: Checkout the repository uses: actions/checkout@v6.0.3 - name: Get version + id: version run: | - version=${{ inputs.version }} + version="${{ inputs.version }}" if [ -n "$version" ]; then - echo "latest_release=$version" >> $GITHUB_ENV + echo "version=${version}" >> "$GITHUB_OUTPUT" else - echo "latest_release=$(curl --header "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" --silent https://api.github.com/repos/$GITHUB_REPOSITORY/releases/latest | jq -r .tag_name | sed s/v// )" >> $GITHUB_ENV + echo "version=$(curl --header "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" --silent "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest" | jq -r .tag_name | sed s/v//)" >> "$GITHUB_OUTPUT" fi - - name: Login to GitHub Container Registry - uses: docker/login-action@v4.2.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Build matrix + id: matrix + run: | + # Derive the per-arch build matrix from config.json. amd64 and aarch64 + # build on native runners (no QEMU). Base image, build args and labels + # all live in the Dockerfile, so nothing extra is needed here. + image_template=$(jq -r '.image' config.json) + architectures=$(jq -r '.arch[]' config.json) + matrix='{"include":[]}' + for arch in $architectures; do + case "${arch}" in + amd64) os="ubuntu-24.04" ;; + aarch64) os="ubuntu-24.04-arm" ;; + *) echo "::error::Unsupported architecture: ${arch}"; exit 1 ;; + esac + image="${image_template//\{arch\}/${arch}}" + matrix=$(jq -c \ + --arg arch "$arch" \ + --arg os "$os" \ + --arg image "$image" \ + '.include += [{arch: $arch, os: $os, image: $image}]' <<< "${matrix}") + done + echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.arch }} image + needs: init + runs-on: ${{ matrix.os }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.init.outputs.matrix) }} + + steps: + - name: Checkout the repository + uses: actions/checkout@v6.0.3 - name: Build and publish - uses: home-assistant/builder@2026.03.2 + uses: home-assistant/builder/actions/build-image@2026.03.2 with: - args: | - --${{ matrix.arch }} \ - --version ${{ env.latest_release }} \ - --target /data/ \ - --addon + arch: ${{ matrix.arch }} + image: ${{ matrix.image }} + image-tags: | + ${{ needs.init.outputs.version }} + latest + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + push: "true" + cosign: "false" + version: ${{ needs.init.outputs.version }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0b5bea1..2ca6202 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,9 +5,6 @@ on: branches: - main paths: - - build.yaml - - config.yaml - - build.json - config.json - Dockerfile - rootfs/** @@ -15,34 +12,71 @@ on: branches: - main paths: - - build.yaml - - config.yaml - - build.json - config.json - Dockerfile - rootfs/** jobs: - build: + init: + name: Initialize build runs-on: ubuntu-latest - name: Build add-on + permissions: + contents: read + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Check out repository + uses: actions/checkout@v6.0.3 + + - name: Build matrix + id: matrix + run: | + # Derive the per-arch build matrix from config.json. amd64 and aarch64 + # build on native runners (no QEMU). Base image, build args and labels + # all live in the Dockerfile, so nothing extra is needed here. + image_template=$(jq -r '.image' config.json) + architectures=$(jq -r '.arch[]' config.json) + matrix='{"include":[]}' + for arch in $architectures; do + case "${arch}" in + amd64) os="ubuntu-24.04" ;; + aarch64) os="ubuntu-24.04-arm" ;; + *) echo "::error::Unsupported architecture: ${arch}"; exit 1 ;; + esac + image="${image_template//\{arch\}/${arch}}" + matrix=$(jq -c \ + --arg arch "$arch" \ + --arg os "$os" \ + --arg image "$image" \ + '.include += [{arch: $arch, os: $os, image: $image}]' <<< "${matrix}") + done + echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.arch }} add-on + needs: init + runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: - matrix: - arch: ["amd64", "aarch64"] + fail-fast: false + matrix: ${{ fromJSON(needs.init.outputs.matrix) }} steps: - name: Check out repository uses: actions/checkout@v6.0.3 - name: Build add-on - uses: home-assistant/builder@2026.03.2 + uses: home-assistant/builder/actions/build-image@2026.03.2 with: - args: | - --test \ - --${{ matrix.arch }} \ - --target /data/ \ - --addon + arch: ${{ matrix.arch }} + image: ${{ matrix.image }} + image-tags: test + container-registry-password: ${{ secrets.GITHUB_TOKEN }} + load: "true" + cosign: "false" + version: test - name: Image labels run: | - docker inspect ghcr.io/lildude/ha-addon-teslamate-${{ matrix.arch }} --format='{{json .Config.Labels}}' | jq + docker inspect ${{ matrix.image }}:test --format='{{json .Config.Labels}}' | jq From a7bc113eb0b1f85769f60f85f27e072b02891141 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Mon, 15 Jun 2026 15:12:14 +0000 Subject: [PATCH 16/26] Wait for Postgres in run script --- rootfs/etc/s6-overlay/s6-rc.d/teslamate/run | 80 ++++++++++++--------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run b/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run index 2ab4c87..93cba36 100755 --- a/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run +++ b/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run @@ -52,44 +52,58 @@ for env_var in $(bashio::config 'env_vars|keys'); do export "${name}=${value}" done -# Create the PostgreSQL database if it doesn't exist -bashio::log.info "Checking for database '$DATABASE_NAME' on $DATABASE_HOST" -if pg_isready -h "$DATABASE_HOST" -p "$DATABASE_PORT" > /dev/null 2>&1; then - # Check the version of PostgreSQL database is v16.7 or later or v17.3 or later - pg_version=$(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT regexp_replace(version(), 'PostgreSQL ([^ ]+) .*', '\\1') AS version") - major_version=$(echo "$pg_version" | cut -d '.' -f 1) - minor_version=$(echo "$pg_version" | cut -d '.' -f 2) - compat=true - case "$major_version" in - 16) - [[ "$minor_version" -lt 7 ]] && compat=false - ;; - 17) - [[ "$minor_version" -lt 3 ]] && compat=false - ;; - *) - [[ "$major_version" -lt 16 ]] && compat=false - ;; - esac - - if [ $compat = false ]; then - bashio::log.error "PostgreSQL version $pg_version is not supported. Please upgrade to v16.7 or later or v17.3 or later." +# Wait for PostgreSQL to be ready before continuing. The add-on can start +# before the database is accepting connections (e.g. on a Home Assistant reboot +# when both add-ons start together, or under load in CI), so retry instead of +# failing immediately - TeslaMate has no built-in retry for this pre-check and +# the s6 finish script would otherwise halt the whole add-on. +bashio::log.info "Waiting for PostgreSQL at '$DATABASE_HOST:$DATABASE_PORT'..." +pg_wait_attempt=0 +pg_wait_max=30 +until pg_isready -h "$DATABASE_HOST" -p "$DATABASE_PORT" > /dev/null 2>&1; do + pg_wait_attempt=$((pg_wait_attempt + 1)) + if [[ "$pg_wait_attempt" -ge "$pg_wait_max" ]]; then + bashio::log.error "PostgreSQL at '$DATABASE_HOST' is not ready or unreachable after $((pg_wait_max * 2))s" exit 1 fi + bashio::log.info "PostgreSQL not ready yet, retrying in 2s (${pg_wait_attempt}/${pg_wait_max})..." + sleep 2 +done +bashio::log.info "PostgreSQL is ready" + +# Create the PostgreSQL database if it doesn't exist +bashio::log.info "Checking for database '$DATABASE_NAME' on $DATABASE_HOST" +# Check the version of PostgreSQL database is v16.7 or later or v17.3 or later +pg_version=$(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT regexp_replace(version(), 'PostgreSQL ([^ ]+) .*', '\\1') AS version") +major_version=$(echo "$pg_version" | cut -d '.' -f 1) +minor_version=$(echo "$pg_version" | cut -d '.' -f 2) +compat=true +case "$major_version" in + 16) + [[ "$minor_version" -lt 7 ]] && compat=false + ;; + 17) + [[ "$minor_version" -lt 3 ]] && compat=false + ;; + *) + [[ "$major_version" -lt 16 ]] && compat=false + ;; +esac + +if [ $compat = false ]; then + bashio::log.error "PostgreSQL version $pg_version is not supported. Please upgrade to v16.7 or later or v17.3 or later." + exit 1 +fi - if [[ -n $(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT datname FROM pg_database WHERE datname = '$DATABASE_NAME'") ]]; then - bashio::log.info "Database $DATABASE_NAME already exists" +if [[ -n $(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT datname FROM pg_database WHERE datname = '$DATABASE_NAME'") ]]; then + bashio::log.info "Database $DATABASE_NAME already exists" +else + if PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -qc "CREATE DATABASE \"$DATABASE_NAME\""; then + bashio::log.info "Database $DATABASE_NAME created" else - if PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -qc "CREATE DATABASE \"$DATABASE_NAME\""; then - bashio::log.info "Database $DATABASE_NAME created" - else - bashio::log.error "Failed to create database $DATABASE_NAME" - exit 1 - fi + bashio::log.error "Failed to create database $DATABASE_NAME" + exit 1 fi -else - bashio::log.error "PostgreSQL at '$DATABASE_HOST' is not ready or unreachable" - exit 1 fi # Import dashboards From 64033b692e5e398522855d13edf320bec7d43cb4 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Mon, 15 Jun 2026 15:31:18 +0000 Subject: [PATCH 17/26] Add more debug diags --- .devcontainer/setup-ha.sh | 25 ++++++++++++++------- rootfs/etc/s6-overlay/s6-rc.d/teslamate/run | 7 ++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index e45e38a..0a6db63 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -507,7 +507,7 @@ configure_and_start_teslamate() { break fi if [ "$state" = "error" ]; then - dump_teslamate_logs 50 + dump_diagnostics 50 err "TeslaMate addon failed to start (state: ${state})" fi debug "Attempt $((attempt + 1))/${max_attempts}: TeslaMate state=${state}" @@ -515,7 +515,7 @@ configure_and_start_teslamate() { attempt=$((attempt + 1)) done if [ "$state" != "started" ]; then - dump_teslamate_logs 50 + dump_diagnostics 50 err "TeslaMate addon did not start in time (state: ${state})" fi @@ -523,12 +523,21 @@ configure_and_start_teslamate() { ok "TeslaMate addon started and ready" } -# dump_teslamate_logs() prints the most recent add-on logs (full lines) so a -# startup failure shows its real cause. It closes any open GitHub Actions log -# group first so the output is never hidden inside a collapsed fold. -dump_teslamate_logs() { +# dump_diagnostics() prints PostgreSQL and TeslaMate add-on logs (full lines) so +# a startup failure shows its real cause. PostgreSQL is reported first because +# TeslaMate halts if it cannot reach the database, so a stopped/crashed postgres +# addon is the most common root cause. Closes any open GitHub Actions log group +# first so the output is never hidden inside a collapsed fold. +dump_diagnostics() { local lines="${1:-50}" group_end + + local pg_state + pg_state=$(supervisor_api_jq "/addons/${POSTGRES_SLUG}/info" '.data.state' 2>/dev/null || true) + warn "PostgreSQL addon state: ${pg_state:-unknown}" + warn "Last ${lines} lines of PostgreSQL add-on logs:" + supervisor_api GET "/addons/${POSTGRES_SLUG}/logs" 2>/dev/null | tail -n "${lines}" >&2 || true + warn "Last ${lines} lines of TeslaMate add-on logs:" supervisor_api GET "/addons/${TESLAMATE_SLUG}/logs" 2>/dev/null | tail -n "${lines}" >&2 || true } @@ -551,7 +560,7 @@ wait_for_teslamate_ready() { # instead of polling uselessly until the timeout. state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state') if [ "$state" = "error" ] || [ "$state" = "stopped" ]; then - dump_teslamate_logs 50 + dump_diagnostics 50 err "TeslaMate crashed during startup (add-on state: ${state})" fi @@ -559,7 +568,7 @@ wait_for_teslamate_ready() { sleep 5 attempt=$((attempt + 1)) done - dump_teslamate_logs 50 + dump_diagnostics 50 err "TeslaMate did not become ready in time (never saw 'TeslaMateWeb.Endpoint' in logs)" } diff --git a/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run b/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run index 93cba36..3752642 100755 --- a/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run +++ b/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run @@ -64,6 +64,13 @@ until pg_isready -h "$DATABASE_HOST" -p "$DATABASE_PORT" > /dev/null 2>&1; do pg_wait_attempt=$((pg_wait_attempt + 1)) if [[ "$pg_wait_attempt" -ge "$pg_wait_max" ]]; then bashio::log.error "PostgreSQL at '$DATABASE_HOST' is not ready or unreachable after $((pg_wait_max * 2))s" + # Distinguish a DNS/networking problem from a database that is simply down, + # so the failure is actionable: resolve the host and report what we find. + if getent hosts "$DATABASE_HOST" > /dev/null 2>&1; then + bashio::log.error "Host '$DATABASE_HOST' resolves to $(getent hosts "$DATABASE_HOST" | awk '{print $1}' | tr '\n' ' ')but is not accepting connections on port $DATABASE_PORT - is the PostgreSQL add-on running and healthy?" + else + bashio::log.error "Host '$DATABASE_HOST' does not resolve - the PostgreSQL add-on may be stopped or on a different network" + fi exit 1 fi bashio::log.info "PostgreSQL not ready yet, retrying in 2s (${pg_wait_attempt}/${pg_wait_max})..." From ff4f4bcbf51f72246176a5dcebdf08f47758615f Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Mon, 15 Jun 2026 15:55:07 +0000 Subject: [PATCH 18/26] Try this --- .devcontainer/setup-ha.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index 0a6db63..d1425a5 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -343,11 +343,17 @@ configure_and_start_postgres() { local postgres_slug="$1" log "Configuring PostgreSQL addon..." + # POSTGRES_INITDB_ARGS=--no-sync makes first-boot initdb skip the recursive + # fsync of the data directory. That fsync is the dominant cost of first-time + # initialisation and on slow CI storage it pushes startup past the postgres + # add-on's hard 2-minute "Postgres did not start" timeout. --no-sync is the + # documented, test-oriented option and is safe here because this is an + # ephemeral dev/CI database where durability does not matter. local options_json options_json=$(jq -n \ --arg pw "$POSTGRES_PASSWORD" \ --arg db "$POSTGRES_DB" \ - '{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, env_vars: []}}') + '{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, POSTGRES_INITDB_ARGS: "--no-sync", env_vars: []}}') supervisor_api_post "/addons/${postgres_slug}/options" \ "${options_json}" > /dev/null From e5e379a38350bea7745ea7df4efaccc55af1dd55 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Mon, 15 Jun 2026 16:09:56 +0000 Subject: [PATCH 19/26] Fudge apparmor --- .devcontainer/setup-ha.sh | 8 +------- .github/workflows/integration-test.yaml | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.devcontainer/setup-ha.sh b/.devcontainer/setup-ha.sh index d1425a5..0a6db63 100755 --- a/.devcontainer/setup-ha.sh +++ b/.devcontainer/setup-ha.sh @@ -343,17 +343,11 @@ configure_and_start_postgres() { local postgres_slug="$1" log "Configuring PostgreSQL addon..." - # POSTGRES_INITDB_ARGS=--no-sync makes first-boot initdb skip the recursive - # fsync of the data directory. That fsync is the dominant cost of first-time - # initialisation and on slow CI storage it pushes startup past the postgres - # add-on's hard 2-minute "Postgres did not start" timeout. --no-sync is the - # documented, test-oriented option and is safe here because this is an - # ephemeral dev/CI database where durability does not matter. local options_json options_json=$(jq -n \ --arg pw "$POSTGRES_PASSWORD" \ --arg db "$POSTGRES_DB" \ - '{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, POSTGRES_INITDB_ARGS: "--no-sync", env_vars: []}}') + '{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, env_vars: []}}') supervisor_api_post "/addons/${postgres_slug}/options" \ "${options_json}" > /dev/null diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index c539bf8..70245d2 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -48,10 +48,20 @@ jobs: fi echo "::endgroup::" >&2 echo "::group::Start Supervisor" >&2 + # AppArmor profile enforcement on add-on containers makes the + # PostgreSQL add-on's first-boot initdb file I/O so slow it blows + # past that add-on's hard 2-minute startup timeout on GitHub Actions + # runners. SUPERVISOR_UNCONFINED only unconfines the Supervisor + # itself, not the add-ons it launches, so instead stub apparmor_parser + # to a no-op and mount it into the Supervisor's privileged container. + # Add-on profiles are then never loaded and add-ons run effectively + # unconfined (matches the proven ha-addon-ghostfolio CI setup). + printf '#!/bin/sh\nexit 0\n' | sudo tee /usr/sbin/apparmor_parser > /dev/null && sudo chmod +x /usr/sbin/apparmor_parser + sudo sed -i 's|docker run --rm --privileged|docker run --rm --privileged -v /usr/sbin/apparmor_parser:/usr/sbin/apparmor_parser:ro|' /usr/bin/supervisor_run if [ -n "${DEBUG:-}" ]; then - SUPERVISOR_UNCONFINED=1 supervisor_run & + supervisor_run & else - SUPERVISOR_UNCONFINED=1 supervisor_run > /dev/null 2>&1 & + supervisor_run > /dev/null 2>&1 & fi echo "::endgroup::" >&2 bash .devcontainer/setup-ha.sh From 6acefacf46f1e8a97f34df17a7cd29a2e6dd00c0 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 21 Jun 2026 11:40:16 +0100 Subject: [PATCH 20/26] Use 4-apps devcontainer image to fix integration test The 5-apps image activates AppArmor for the Supervisor and apps whenever the host kernel supports it, which it does on GHA ubuntu-latest runners. That confines the PostgreSQL add-on under its AppArmor profile, and on Ubuntu kernels the profile either fails to apply (runc: "no such file or directory" on .../attr/apparmor/exec) or slows initdb enough to blow the 2-minute startup timeout. SUPERVISOR_UNCONFINED only unconfines the Supervisor container, not the apps, so it doesn't help here. Switch to the 4-apps image, which doesn't activate AppArmor in the devcontainer, so add-ons run unconfined and PostgreSQL starts cleanly - matching the proven-working ha-addon-ghostfolio setup. Also drop the SUPERVISOR_CHANNEL=beta debugging artifact, which is unrelated and not needed. --- .devcontainer/devcontainer.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2d59063..8a1e3d8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "HA Addon: TeslaMate", - "image": "ghcr.io/home-assistant/devcontainer:5-apps", + "image": "ghcr.io/home-assistant/devcontainer:4-apps", "overrideCommand": false, "appPort": ["7123:8123", "7357:4357"], "postStartCommand": "bash devcontainer_bootstrap", @@ -8,8 +8,7 @@ "workspaceFolder": "/mnt/supervisor/addons/local/${localWorkspaceFolderBasename}", "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached", "containerEnv": { - "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}", - "SUPERVISOR_CHANNEL": "beta" + "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}" }, "customizations": { "vscode": { From 76499d8a7d571c1b5e82d6e50dcdcee76bdebb80 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 21 Jun 2026 10:50:33 +0000 Subject: [PATCH 21/26] Simplify --- .github/workflows/integration-test.yaml | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 70245d2..33ef4b2 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -34,28 +34,10 @@ jobs: DEBUG=${{ vars.DEBUG }} GITHUB_ACTIONS=true runCmd: | - echo "::group::Build add-on locally" >&2 - # The committed config.json carries an "image" key (required by the - # release/publish pipeline) plus "version": "dev". Left in place, the - # Supervisor pulls ghcr.io/...-{arch}:dev - a tag that is never - # published - and the integration test fails. Strip the key BEFORE - # starting Supervisor so its first store scan builds from the local - # Dockerfile; stripping later is unreliable because /store/reload - # does not re-read an already-scanned local add-on's image field. if jq -e 'has("image")' config.json >/dev/null 2>&1; then jq 'del(.image)' config.json > config.json.tmp && mv config.json.tmp config.json - echo "Removed 'image' key from config.json - add-on will build locally" >&2 fi - echo "::endgroup::" >&2 - echo "::group::Start Supervisor" >&2 - # AppArmor profile enforcement on add-on containers makes the - # PostgreSQL add-on's first-boot initdb file I/O so slow it blows - # past that add-on's hard 2-minute startup timeout on GitHub Actions - # runners. SUPERVISOR_UNCONFINED only unconfines the Supervisor - # itself, not the add-ons it launches, so instead stub apparmor_parser - # to a no-op and mount it into the Supervisor's privileged container. - # Add-on profiles are then never loaded and add-ons run effectively - # unconfined (matches the proven ha-addon-ghostfolio CI setup). + printf '#!/bin/sh\nexit 0\n' | sudo tee /usr/sbin/apparmor_parser > /dev/null && sudo chmod +x /usr/sbin/apparmor_parser sudo sed -i 's|docker run --rm --privileged|docker run --rm --privileged -v /usr/sbin/apparmor_parser:/usr/sbin/apparmor_parser:ro|' /usr/bin/supervisor_run if [ -n "${DEBUG:-}" ]; then @@ -63,5 +45,5 @@ jobs: else supervisor_run > /dev/null 2>&1 & fi - echo "::endgroup::" >&2 + bash .devcontainer/setup-ha.sh From 0ab14a7fa876caf83154be3884dc8082adbe07ed Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 21 Jun 2026 11:19:36 +0000 Subject: [PATCH 22/26] Put back original file --- rootfs/etc/s6-overlay/s6-rc.d/teslamate/run | 87 ++++++++------------- 1 file changed, 33 insertions(+), 54 deletions(-) diff --git a/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run b/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run index 3752642..2ab4c87 100755 --- a/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run +++ b/rootfs/etc/s6-overlay/s6-rc.d/teslamate/run @@ -52,65 +52,44 @@ for env_var in $(bashio::config 'env_vars|keys'); do export "${name}=${value}" done -# Wait for PostgreSQL to be ready before continuing. The add-on can start -# before the database is accepting connections (e.g. on a Home Assistant reboot -# when both add-ons start together, or under load in CI), so retry instead of -# failing immediately - TeslaMate has no built-in retry for this pre-check and -# the s6 finish script would otherwise halt the whole add-on. -bashio::log.info "Waiting for PostgreSQL at '$DATABASE_HOST:$DATABASE_PORT'..." -pg_wait_attempt=0 -pg_wait_max=30 -until pg_isready -h "$DATABASE_HOST" -p "$DATABASE_PORT" > /dev/null 2>&1; do - pg_wait_attempt=$((pg_wait_attempt + 1)) - if [[ "$pg_wait_attempt" -ge "$pg_wait_max" ]]; then - bashio::log.error "PostgreSQL at '$DATABASE_HOST' is not ready or unreachable after $((pg_wait_max * 2))s" - # Distinguish a DNS/networking problem from a database that is simply down, - # so the failure is actionable: resolve the host and report what we find. - if getent hosts "$DATABASE_HOST" > /dev/null 2>&1; then - bashio::log.error "Host '$DATABASE_HOST' resolves to $(getent hosts "$DATABASE_HOST" | awk '{print $1}' | tr '\n' ' ')but is not accepting connections on port $DATABASE_PORT - is the PostgreSQL add-on running and healthy?" - else - bashio::log.error "Host '$DATABASE_HOST' does not resolve - the PostgreSQL add-on may be stopped or on a different network" - fi - exit 1 - fi - bashio::log.info "PostgreSQL not ready yet, retrying in 2s (${pg_wait_attempt}/${pg_wait_max})..." - sleep 2 -done -bashio::log.info "PostgreSQL is ready" - # Create the PostgreSQL database if it doesn't exist bashio::log.info "Checking for database '$DATABASE_NAME' on $DATABASE_HOST" -# Check the version of PostgreSQL database is v16.7 or later or v17.3 or later -pg_version=$(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT regexp_replace(version(), 'PostgreSQL ([^ ]+) .*', '\\1') AS version") -major_version=$(echo "$pg_version" | cut -d '.' -f 1) -minor_version=$(echo "$pg_version" | cut -d '.' -f 2) -compat=true -case "$major_version" in - 16) - [[ "$minor_version" -lt 7 ]] && compat=false - ;; - 17) - [[ "$minor_version" -lt 3 ]] && compat=false - ;; - *) - [[ "$major_version" -lt 16 ]] && compat=false - ;; -esac - -if [ $compat = false ]; then - bashio::log.error "PostgreSQL version $pg_version is not supported. Please upgrade to v16.7 or later or v17.3 or later." - exit 1 -fi +if pg_isready -h "$DATABASE_HOST" -p "$DATABASE_PORT" > /dev/null 2>&1; then + # Check the version of PostgreSQL database is v16.7 or later or v17.3 or later + pg_version=$(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT regexp_replace(version(), 'PostgreSQL ([^ ]+) .*', '\\1') AS version") + major_version=$(echo "$pg_version" | cut -d '.' -f 1) + minor_version=$(echo "$pg_version" | cut -d '.' -f 2) + compat=true + case "$major_version" in + 16) + [[ "$minor_version" -lt 7 ]] && compat=false + ;; + 17) + [[ "$minor_version" -lt 3 ]] && compat=false + ;; + *) + [[ "$major_version" -lt 16 ]] && compat=false + ;; + esac + + if [ $compat = false ]; then + bashio::log.error "PostgreSQL version $pg_version is not supported. Please upgrade to v16.7 or later or v17.3 or later." + exit 1 + fi -if [[ -n $(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT datname FROM pg_database WHERE datname = '$DATABASE_NAME'") ]]; then - bashio::log.info "Database $DATABASE_NAME already exists" -else - if PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -qc "CREATE DATABASE \"$DATABASE_NAME\""; then - bashio::log.info "Database $DATABASE_NAME created" + if [[ -n $(PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -Atqc "SELECT datname FROM pg_database WHERE datname = '$DATABASE_NAME'") ]]; then + bashio::log.info "Database $DATABASE_NAME already exists" else - bashio::log.error "Failed to create database $DATABASE_NAME" - exit 1 + if PGPASSWORD="$DATABASE_PASS" psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USER" postgres -qc "CREATE DATABASE \"$DATABASE_NAME\""; then + bashio::log.info "Database $DATABASE_NAME created" + else + bashio::log.error "Failed to create database $DATABASE_NAME" + exit 1 + fi fi +else + bashio::log.error "PostgreSQL at '$DATABASE_HOST' is not ready or unreachable" + exit 1 fi # Import dashboards From d4eb61330479d83aa41489792ab2ea41167e4462 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 21 Jun 2026 11:20:43 +0000 Subject: [PATCH 23/26] Update to latest TeslaMate --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e8acb9e..606a184 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG teslamate_version=3.0.0 +ARG teslamate_version=4.0.1 FROM teslamate/grafana:${teslamate_version} AS grafana #--- From 1acd2fb23d960c426dcdeb78da1ac99f074ddb1c Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 21 Jun 2026 11:29:50 +0000 Subject: [PATCH 24/26] Add back TeslaMate version badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1a2a8e4..6d07b28 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Thanks to @matt-FFFFFF for maintaining this add-on in the past. [![Sponsor me to maintain this add-on][sponsor-badge]](https://github.com/sponsors/lildude) +![TeslaMate Version][teslamate-version] ![Ingress][ingres-badge] ![Supported Architectures][archs] @@ -84,5 +85,6 @@ Everything should pick up where it was before. [repo-btn]: https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg [sponsor-badge]: https://img.shields.io/badge/Sponsor_Me-%E2%9D%A4-ec6cb9?logo=GitHub [teslamate-backup]: https://docs.teslamate.org/docs/maintenance/backup_restore +[teslamate-version]: https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fgithub.com%2Flildude%2Fha-addon-teslamate%2Fblob%2Fd4eb61330479d83aa41489792ab2ea41167e4462%2FDockerfile&search=teslamate_version%3D(%3F%3Ccurrent_ver%3E%5Cd%2B%5C.%5Cd%2B%5C.%5Cd%2B)&replace=%241&label=TeslaMate%20Version [teslamate]: https://github.com/teslamate-org/teslamate/ [timescaledb]: https://github.com/expaso/hassos-addon-timescaledb From 606573acf330489d2e47121df0348f190b485d47 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 21 Jun 2026 11:31:17 +0000 Subject: [PATCH 25/26] Fix URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d07b28..0d7d9d6 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,6 @@ Everything should pick up where it was before. [repo-btn]: https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg [sponsor-badge]: https://img.shields.io/badge/Sponsor_Me-%E2%9D%A4-ec6cb9?logo=GitHub [teslamate-backup]: https://docs.teslamate.org/docs/maintenance/backup_restore -[teslamate-version]: https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fgithub.com%2Flildude%2Fha-addon-teslamate%2Fblob%2Fd4eb61330479d83aa41489792ab2ea41167e4462%2FDockerfile&search=teslamate_version%3D(%3F%3Ccurrent_ver%3E%5Cd%2B%5C.%5Cd%2B%5C.%5Cd%2B)&replace=%241&label=TeslaMate%20Version +[teslamate-version]: https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fgithub.com%2Flildude%2Fha-addon-teslamate%2Fblob%2Fmain%2FDockerfile&search=teslamate_version%3D(%3F%3Ccurrent_ver%3E%5Cd%2B%5C.%5Cd%2B%5C.%5Cd%2B)&replace=%241&label=TeslaMate%20Version [teslamate]: https://github.com/teslamate-org/teslamate/ [timescaledb]: https://github.com/expaso/hassos-addon-timescaledb From 8faefafbbde059f8b6e111e17b5c3d2c30341e06 Mon Sep 17 00:00:00 2001 From: Colin Seymour Date: Sun, 21 Jun 2026 12:01:24 +0000 Subject: [PATCH 26/26] Add actions SHAs --- .github/workflows/integration-test.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml index 33ef4b2..71ff405 100644 --- a/.github/workflows/integration-test.yaml +++ b/.github/workflows/integration-test.yaml @@ -25,10 +25,10 @@ jobs: timeout-minutes: 30 steps: - name: Check out repository - uses: actions/checkout@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Run integration test in devcontainer - uses: devcontainers/ci@v0.3 + uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 with: env: | DEBUG=${{ vars.DEBUG }}