mirror of
https://github.com/lildude/ha-addon-teslamate.git
synced 2026-10-13 13:10:30 -04:00
Update integration tests and update dev environment (#152)
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "ghcr.io/eitsupi/devcontainer-features/jq-likes@sha256:6a8b044f9f424097ec6d212167836b26994a76a556cbcb202084be79fe8032bd",
|
||||||
|
"integrity": "sha256:6a8b044f9f424097ec6d212167836b26994a76a556cbcb202084be79fe8032bd"
|
||||||
|
},
|
||||||
|
"ghcr.io/jungaretti/features/ripgrep:1": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "ghcr.io/jungaretti/features/ripgrep@sha256:c0922b6f4a9184080c8435b6ef25983dee6c734733f0a749d852ad0d33de2253",
|
||||||
|
"integrity": "sha256:c0922b6f4a9184080c8435b6ef25983dee6c734733f0a749d852ad0d33de2253"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "HA Addon: TeslaMate",
|
"name": "HA Addon: TeslaMate",
|
||||||
"image": "ghcr.io/home-assistant/devcontainer:4-apps",
|
"image": "ghcr.io/home-assistant/devcontainer:6-apps",
|
||||||
"overrideCommand": false,
|
"overrideCommand": false,
|
||||||
"appPort": ["7123:8123", "7357:4357"],
|
"remoteUser": "vscode",
|
||||||
|
"appPort": ["7123:8123", "7180:80", "7357:4357"],
|
||||||
"postStartCommand": "bash devcontainer_bootstrap",
|
"postStartCommand": "bash devcontainer_bootstrap",
|
||||||
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
"runArgs": ["-e", "GIT_EDITOR=code --wait", "--privileged"],
|
||||||
"workspaceFolder": "/mnt/supervisor/addons/local/${localWorkspaceFolderBasename}",
|
"workspaceFolder": "/mnt/supervisor/addons/local/${localWorkspaceFolderBasename}",
|
||||||
"workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached",
|
"workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached",
|
||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
|
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}",
|
||||||
|
"SUPERVISOR_CHANNEL": "stable"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {},
|
||||||
|
"ghcr.io/jungaretti/features/ripgrep:1": {}
|
||||||
},
|
},
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
@@ -33,6 +39,6 @@
|
|||||||
"mounts": [
|
"mounts": [
|
||||||
"type=volume,target=/var/lib/docker",
|
"type=volume,target=/var/lib/docker",
|
||||||
"type=volume,target=/var/lib/containerd",
|
"type=volume,target=/var/lib/containerd",
|
||||||
"type=volume,target=/mnt/supervisor"
|
"type=volume,target=/mnt/supervisor",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Remove an add-on's registry image before the Supervisor starts so it must
|
||||||
|
# build the current checkout's Dockerfile.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
config="${1:-config.json}"
|
||||||
|
|
||||||
|
if [ ! -f "$config" ]; then
|
||||||
|
echo "ERROR: Add-on config not found: ${config}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! jq empty "$config"; then
|
||||||
|
echo "ERROR: Invalid add-on config: ${config}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if jq -e 'has("image")' "$config" > /dev/null; then
|
||||||
|
temporary_config=$(mktemp "${config}.XXXXXX")
|
||||||
|
trap 'rm -f "$temporary_config"' EXIT
|
||||||
|
|
||||||
|
jq 'del(.image)' "$config" > "$temporary_config"
|
||||||
|
mv "$temporary_config" "$config"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if jq -e 'has("image")' "$config" > /dev/null; then
|
||||||
|
echo "ERROR: Failed to remove image from ${config}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Registry image disabled; the Supervisor will build the current checkout"
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-integration-test.sh - Boot the Supervisor and run setup-ha.sh against it.
|
||||||
|
#
|
||||||
|
# Usage: bash .devcontainer/run-integration-test.sh [channel]
|
||||||
|
#
|
||||||
|
# The channel (stable, beta or dev) selects which Home Assistant release the
|
||||||
|
# Supervisor installs. It defaults to stable so that CI tests what users actually
|
||||||
|
# run; the devcontainer image itself defaults to dev, which means unreleased
|
||||||
|
# Home Assistant changes would otherwise land straight in CI.
|
||||||
|
#
|
||||||
|
# Set DEBUG=1 to stream the Supervisor log instead of capturing it.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export SUPERVISOR_CHANNEL="${1:-stable}"
|
||||||
|
SUPERVISOR_LOG="/tmp/supervisor.log"
|
||||||
|
|
||||||
|
bash .devcontainer/prepare-local-addon.sh
|
||||||
|
|
||||||
|
# The v6 devcontainer exposes host AppArmor through OS Agent, but profiles
|
||||||
|
# applied to containers in its nested Docker daemon break the PostgreSQL image.
|
||||||
|
sudo systemctl stop haos-agent
|
||||||
|
|
||||||
|
# supervisor_run hardcodes SUPERVISOR_DEV=1, which makes the Supervisor force its
|
||||||
|
# update channel to dev and so install nightly Home Assistant builds whatever
|
||||||
|
# channel is asked for.
|
||||||
|
sudo sed -i '/-e SUPERVISOR_DEV=1/d' /usr/bin/supervisor_run
|
||||||
|
|
||||||
|
# The channel is otherwise only reachable through the Supervisor API, by which
|
||||||
|
# point it has already installed Home Assistant, so seed it before first boot.
|
||||||
|
sudo mkdir -p /mnt/supervisor
|
||||||
|
printf '{"channel": "%s"}\n' "${SUPERVISOR_CHANNEL}" | sudo tee /mnt/supervisor/updater.json > /dev/null
|
||||||
|
|
||||||
|
echo "Home Assistant channel: ${SUPERVISOR_CHANNEL}"
|
||||||
|
|
||||||
|
if [ -n "${DEBUG:-}" ]; then
|
||||||
|
supervisor_run 2>&1 | tee "${SUPERVISOR_LOG}" &
|
||||||
|
else
|
||||||
|
supervisor_run > "${SUPERVISOR_LOG}" 2>&1 &
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! bash .devcontainer/setup-ha.sh; then
|
||||||
|
echo "::group::Supervisor log (last 300 lines, image pull progress omitted)"
|
||||||
|
grep -v -E "docker_image_pull_update|pull_progress" "${SUPERVISOR_LOG}" | tail -n 300
|
||||||
|
echo "::endgroup::"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+200
-297
@@ -1,15 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# setup-ha.sh - Idempotent Home Assistant setup for TeslaMate development
|
# setup-ha.sh - Idempotent Home Assistant setup for addon development
|
||||||
#
|
#
|
||||||
# This script:
|
# This script:
|
||||||
# 1. Completes HA onboarding (creates admin user)
|
# 1. Completes HA onboarding (creates admin user)
|
||||||
# 2. Adds the alexbelgium addon repository (for PostgreSQL)
|
# 2. Adds the alexbelgium addon repository (for PostgreSQL)
|
||||||
# 3. Installs and starts PostgreSQL 17
|
# 3. Installs and starts PostgreSQL 17
|
||||||
# 4. Installs, configures, and starts the local TeslaMate addon
|
# 4. Installs, configures, and starts the local 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
|
# Usage: bash .devcontainer/setup-ha.sh
|
||||||
# DEBUG=1 bash .devcontainer/setup-ha.sh # verbose output + error details
|
# DEBUG=1 bash .devcontainer/setup-ha.sh # verbose output + error details
|
||||||
@@ -18,96 +14,53 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HA_URL="http://localhost:8123"
|
# --- 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"
|
ALEXBELGIUM_REPO="https://github.com/alexbelgium/hassio-addons"
|
||||||
ADMIN_USER="admin"
|
ADMIN_USER="admin"
|
||||||
ADMIN_PASS="pass"
|
ADMIN_PASS="pass"
|
||||||
CLIENT_ID="${HA_URL}/"
|
|
||||||
|
|
||||||
|
# --- PostgreSQL configuration ---
|
||||||
POSTGRES_PASSWORD="homeassistant"
|
POSTGRES_PASSWORD="homeassistant"
|
||||||
POSTGRES_USER="postgres"
|
POSTGRES_USER="postgres"
|
||||||
POSTGRES_DB="teslamate"
|
POSTGRES_DB="${ADDON_NAME,,}"
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
# Addon slug = repo_hash + "_" + addon_slug_from_config
|
# Addon slug = repo_hash + "_" + addon_slug_from_config
|
||||||
# Repo hash: first 8 chars of sha1("https://github.com/alexbelgium/hassio-addons")
|
# Repo hash: first 8 chars of sha1("https://github.com/alexbelgium/hassio-addons")
|
||||||
POSTGRES_SLUG="db21ed7f_postgres_latest"
|
POSTGRES_SLUG="db21ed7f_postgres_latest"
|
||||||
|
|
||||||
TESLAMATE_SLUG="local_teslamate"
|
|
||||||
TESLAMATE_PORT=4000
|
|
||||||
TIMEZONE="Europe/London"
|
|
||||||
|
|
||||||
# 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 ---
|
# --- Helpers ---
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[0;33m'
|
YELLOW='\033[0;33m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
DIM='\033[2m'
|
|
||||||
BOLD='\033[1m'
|
BOLD='\033[1m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
log() { echo -e "${CYAN}==>${NC} ${BOLD}$*${NC}"; }
|
log() { echo -e "${CYAN}==>${NC} ${BOLD}$*${NC}"; }
|
||||||
warn() { echo -e "${YELLOW}WARNING:${NC} $*" >&2; }
|
warn() { echo -e "${YELLOW}WARNING:${NC} $*" >&2; }
|
||||||
err() { group_end; echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; }
|
err() { echo -e "${RED}ERROR:${NC} $*" >&2; exit 1; }
|
||||||
ok() { echo -e "${CYAN}==>${NC} ${GREEN}$*${NC}"; }
|
ok() { echo -e "${CYAN}==>${NC} ${GREEN}$*${NC}"; }
|
||||||
|
|
||||||
debug() {
|
# Parse JSON, aborting with the offending body instead of a bare jq parse error
|
||||||
[ -n "$DEBUG" ] || return 0
|
jq_or_die() {
|
||||||
echo -e "${DIM}DEBUG:${NC} $*" >&2
|
local json="$1" filter="$2" context="$3"
|
||||||
}
|
if [ -z "$json" ] || ! jq empty <<< "$json" 2>/dev/null; then
|
||||||
|
err "${context}: expected JSON, got: ${json}"
|
||||||
|
|
||||||
debug_response() {
|
|
||||||
[ -n "$DEBUG" ] || return 0
|
|
||||||
local resp="$1" max=800
|
|
||||||
if [ "${#resp}" -gt "$max" ]; then
|
|
||||||
debug "response (truncated): ${resp:0:$max}..."
|
|
||||||
else
|
|
||||||
debug "response: ${resp}"
|
|
||||||
fi
|
fi
|
||||||
}
|
jq -r "$filter" <<< "$json"
|
||||||
|
|
||||||
# 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() {
|
|
||||||
[ -n "$DEBUG" ] && [ "${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
|
# Run Supervisor API call via hassio_cli container
|
||||||
@@ -115,67 +68,28 @@ run_step() {
|
|||||||
# is expanded by the inner shell (where the env var exists), not the outer bash.
|
# is expanded by the inner shell (where the env var exists), not the outer bash.
|
||||||
supervisor_api() {
|
supervisor_api() {
|
||||||
local method="$1" endpoint="$2"
|
local method="$1" endpoint="$2"
|
||||||
debug "API ${method} ${endpoint}"
|
docker exec hassio_cli sh -c \
|
||||||
local resp
|
'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" http://supervisor'"${endpoint}"
|
||||||
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() {
|
supervisor_api_with_body() {
|
||||||
local method="$1" endpoint="$2" body="$3"
|
local method="$1" endpoint="$2" body="$3"
|
||||||
debug "API ${method} ${endpoint} body=${body}"
|
docker exec -e "BODY=${body}" hassio_cli sh -c \
|
||||||
local resp
|
'curl -s -X '"${method}"' -H "Authorization: Bearer $SUPERVISOR_TOKEN" -H "Content-Type: application/json" -d "$BODY" http://supervisor'"${endpoint}"
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# supervisor_api_post() performs a state-changing POST (optionally with a JSON
|
# Query a specific jq field from a Supervisor API response.
|
||||||
# body) and aborts via err() if the Supervisor returns {"result":"error",...}.
|
# Supervisor reports HTTP errors as plain text (e.g. "404: Not Found"), which is
|
||||||
# These endpoints (install/start/options/reload) are synchronous, so without
|
# not JSON, so surface those and return nothing to let callers keep polling.
|
||||||
# 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() {
|
|
||||||
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
|
|
||||||
# Runs curl and jq inside hassio_cli to avoid docker exec stdout pipe issues
|
|
||||||
supervisor_api_jq() {
|
supervisor_api_jq() {
|
||||||
local endpoint="$1" jq_filter="$2"
|
local endpoint="$1" jq_filter="$2"
|
||||||
docker exec -e "JQ_FILTER=${jq_filter}" hassio_cli sh -c \
|
local response
|
||||||
'curl -s -H "Authorization: Bearer $SUPERVISOR_TOKEN" http://supervisor'"${endpoint}"' | jq -r "$JQ_FILTER"'
|
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)
|
# Check if a jq expression matches (returns 0 if match found, 1 otherwise)
|
||||||
@@ -187,45 +101,83 @@ supervisor_api_jq_test() {
|
|||||||
|
|
||||||
# --- Step 0: Wait for HA to be ready ---
|
# --- Step 0: Wait for HA to be ready ---
|
||||||
|
|
||||||
wait_for_ha() {
|
# Print an addon's log to help diagnose a failure
|
||||||
log "Waiting for Home Assistant to be ready..."
|
dump_addon_logs() {
|
||||||
local max_attempts=60
|
local slug="$1"
|
||||||
local attempt=0
|
echo "::group::${slug} logs"
|
||||||
local http_code
|
supervisor_api GET "/addons/${slug}/logs" || true
|
||||||
while [ "$attempt" -lt "$max_attempts" ]; do
|
echo "::endgroup::"
|
||||||
# 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)
|
# Best-effort Home Assistant Core state from the Supervisor, for diagnostics
|
||||||
if [ "$http_code" = "200" ] || [ "$http_code" = "404" ]; then
|
ha_core_state() {
|
||||||
ok "Home Assistant is ready"
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
debug "Attempt $((attempt + 1))/${max_attempts}: Home Assistant not ready yet"
|
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
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
err "Home Assistant did not become ready in time"
|
err "Home Assistant did not become ready in time (core=$(ha_core_state) ${HA_PROBE})"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Step 1: Complete onboarding ---
|
# --- Step 1: Complete onboarding ---
|
||||||
|
|
||||||
complete_onboarding() {
|
complete_onboarding() {
|
||||||
log "Checking onboarding status..."
|
log "Checking onboarding status..."
|
||||||
# Capture the body and HTTP status separately. A configured instance returns
|
local onboarding
|
||||||
# 404 here because the onboarding endpoints are removed once onboarding
|
onboarding=$(curl -sfL "${HA_URL}/api/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
|
local user_done
|
||||||
user_done=$(echo "$onboarding" | jq -r '.[] | select(.step == "user") | .done')
|
user_done=$(jq_or_die "$onboarding" '.[] | select(.step == "user") | .done' "Onboarding status")
|
||||||
|
|
||||||
if [ "$user_done" = "true" ]; then
|
if [ "$user_done" = "true" ]; then
|
||||||
ok "Onboarding already completed"
|
ok "Onboarding already completed"
|
||||||
@@ -234,13 +186,12 @@ complete_onboarding() {
|
|||||||
|
|
||||||
log "Creating admin user '${ADMIN_USER}'..."
|
log "Creating admin user '${ADMIN_USER}'..."
|
||||||
local auth_response
|
local auth_response
|
||||||
auth_response=$(curl -sf -X POST "${HA_URL}/api/onboarding/users" \
|
auth_response=$(curl -sfL -X POST "${HA_URL}/api/onboarding/users" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"client_id\":\"${CLIENT_ID}\",\"name\":\"Admin\",\"username\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\",\"language\":\"en\"}")
|
-d "{\"client_id\":\"${CLIENT_ID}\",\"name\":\"Admin\",\"username\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\",\"language\":\"en\"}")
|
||||||
debug "users response: ${auth_response}"
|
|
||||||
|
|
||||||
local auth_code
|
local auth_code
|
||||||
auth_code=$(echo "$auth_response" | jq -r '.auth_code')
|
auth_code=$(jq_or_die "$auth_response" '.auth_code' "Create admin user")
|
||||||
|
|
||||||
if [ -z "$auth_code" ] || [ "$auth_code" = "null" ]; then
|
if [ -z "$auth_code" ] || [ "$auth_code" = "null" ]; then
|
||||||
err "Failed to create user: ${auth_response}"
|
err "Failed to create user: ${auth_response}"
|
||||||
@@ -248,25 +199,34 @@ complete_onboarding() {
|
|||||||
|
|
||||||
# Exchange auth code for access token
|
# Exchange auth code for access token
|
||||||
local token_response
|
local token_response
|
||||||
token_response=$(curl -sf -X POST "${HA_URL}/auth/token" \
|
token_response=$(curl -sfL -X POST "${HA_URL}/auth/token" \
|
||||||
--data-urlencode "grant_type=authorization_code" \
|
--data-urlencode "grant_type=authorization_code" \
|
||||||
--data-urlencode "code=${auth_code}" \
|
--data-urlencode "code=${auth_code}" \
|
||||||
--data-urlencode "client_id=${CLIENT_ID}")
|
--data-urlencode "client_id=${CLIENT_ID}")
|
||||||
debug "token response: ${token_response}"
|
|
||||||
|
|
||||||
local access_token
|
local access_token
|
||||||
access_token=$(echo "$token_response" | jq -r '.access_token')
|
access_token=$(jq_or_die "$token_response" '.access_token' "Token exchange")
|
||||||
|
|
||||||
if [ -z "$access_token" ] || [ "$access_token" = "null" ]; then
|
if [ -z "$access_token" ] || [ "$access_token" = "null" ]; then
|
||||||
err "Failed to get access token: ${token_response}"
|
err "Failed to get access token: ${token_response}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Complete remaining onboarding steps (best-effort; failures are non-fatal)
|
# Complete remaining onboarding steps
|
||||||
log "Completing onboarding steps..."
|
log "Completing onboarding steps..."
|
||||||
ha_post_optional "$access_token" "/api/onboarding/core_config" "{}"
|
curl -sfL -X POST "${HA_URL}/api/onboarding/core_config" \
|
||||||
ha_post_optional "$access_token" "/api/onboarding/analytics" "{}"
|
-H "Authorization: Bearer ${access_token}" \
|
||||||
ha_post_optional "$access_token" "/api/onboarding/integration" \
|
-H "Content-Type: application/json" \
|
||||||
"{\"client_id\":\"${CLIENT_ID}\",\"redirect_uri\":\"${HA_URL}/\"}"
|
-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"
|
ok "Onboarding complete"
|
||||||
}
|
}
|
||||||
@@ -282,7 +242,7 @@ add_addon_repo() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "Adding addon repository: ${ALEXBELGIUM_REPO}"
|
log "Adding addon repository: ${ALEXBELGIUM_REPO}"
|
||||||
supervisor_api_post "/store/repositories" \
|
supervisor_api_with_body POST "/store/repositories" \
|
||||||
"{\"repository\":\"${ALEXBELGIUM_REPO}\"}" > /dev/null
|
"{\"repository\":\"${ALEXBELGIUM_REPO}\"}" > /dev/null
|
||||||
|
|
||||||
# Wait for the store to refresh by checking if the postgres addon is available
|
# Wait for the store to refresh by checking if the postgres addon is available
|
||||||
@@ -294,7 +254,6 @@ add_addon_repo() {
|
|||||||
ok "Store refreshed successfully"
|
ok "Store refreshed successfully"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
debug "Attempt $((attempt + 1))/${max_attempts}: postgres addon not in store yet"
|
|
||||||
sleep 5
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
@@ -318,7 +277,7 @@ install_postgres() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "Installing PostgreSQL addon..."
|
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
|
# Wait for installation to complete
|
||||||
log "Waiting for PostgreSQL installation..."
|
log "Waiting for PostgreSQL installation..."
|
||||||
@@ -330,7 +289,6 @@ install_postgres() {
|
|||||||
ok "PostgreSQL addon installed (version: ${version})"
|
ok "PostgreSQL addon installed (version: ${version})"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
debug "Attempt $((attempt + 1))/${max_attempts}: PostgreSQL not installed yet"
|
|
||||||
sleep 5
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
@@ -349,7 +307,7 @@ configure_and_start_postgres() {
|
|||||||
--arg db "$POSTGRES_DB" \
|
--arg db "$POSTGRES_DB" \
|
||||||
'{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, env_vars: []}}')
|
'{options: {POSTGRES_PASSWORD: $pw, POSTGRES_DB: $db, env_vars: []}}')
|
||||||
|
|
||||||
supervisor_api_post "/addons/${postgres_slug}/options" \
|
supervisor_api_with_body POST "/addons/${postgres_slug}/options" \
|
||||||
"${options_json}" > /dev/null
|
"${options_json}" > /dev/null
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
@@ -362,7 +320,7 @@ configure_and_start_postgres() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log "Starting PostgreSQL addon..."
|
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
|
# Wait for postgres to be ready
|
||||||
log "Waiting for PostgreSQL to be ready..."
|
log "Waiting for PostgreSQL to be ready..."
|
||||||
@@ -372,105 +330,83 @@ configure_and_start_postgres() {
|
|||||||
state=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.state')
|
state=$(supervisor_api_jq "/addons/${postgres_slug}/info" '.data.state')
|
||||||
if [ "$state" = "started" ]; then
|
if [ "$state" = "started" ]; then
|
||||||
ok "PostgreSQL addon is running"
|
ok "PostgreSQL addon is running"
|
||||||
# Give postgres time to initialize the database
|
wait_for_postgres_ready "$postgres_slug"
|
||||||
sleep 10
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
debug "Attempt $((attempt + 1))/${max_attempts}: PostgreSQL state=${state}"
|
|
||||||
sleep 5
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
err "PostgreSQL addon did not start in time"
|
err "PostgreSQL addon did not start in time"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Step 5: Install and configure TeslaMate ---
|
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}")
|
||||||
|
|
||||||
# Remove the "image" key from config.json so the Supervisor builds the addon
|
log "Waiting for PostgreSQL database to accept connections..."
|
||||||
# image locally from the Dockerfile instead of pulling a pre-built image.
|
local max_attempts=60
|
||||||
remove_addon_image() {
|
local attempt=0
|
||||||
log "Ensuring TeslaMate addon builds locally..."
|
local container probe
|
||||||
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
||||||
if [ ! -f "$CONFIG_JSON" ]; then
|
for container in "${containers[@]}"; do
|
||||||
warn "config.json not found at ${CONFIG_JSON} - skipping image removal"
|
if probe=$(docker exec -e "PGPASSWORD=${POSTGRES_PASSWORD}" "$container" \
|
||||||
return 0
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT 1" 2>&1); then
|
||||||
fi
|
ok "PostgreSQL database is ready"
|
||||||
|
return 0
|
||||||
if ! jq -e 'has("image")' "$CONFIG_JSON" > /dev/null 2>&1; then
|
fi
|
||||||
ok "config.json already has no 'image' key - addon will build locally"
|
done
|
||||||
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, 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
|
|
||||||
|
|
||||||
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
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
err "Store still reports a prebuilt image for TeslaMate after reload; aborting to avoid pulling a non-existent image"
|
warn "Last psql output: ${probe}"
|
||||||
|
dump_addon_logs "$postgres_slug"
|
||||||
|
err "PostgreSQL database did not become ready in time"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_teslamate() {
|
# --- Step 5: Install and configure addon ---
|
||||||
log "Checking TeslaMate addon status..."
|
|
||||||
|
install_addon() {
|
||||||
|
log "Checking ${ADDON_NAME} addon status..."
|
||||||
local version
|
local version
|
||||||
version=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.version // empty')
|
version=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.version // empty')
|
||||||
|
|
||||||
if [ -n "$version" ]; then
|
if [ -n "$version" ]; then
|
||||||
local state
|
local state
|
||||||
state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state')
|
state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state')
|
||||||
ok "TeslaMate addon already installed (version: ${version}, state: ${state})"
|
ok "${ADDON_NAME} addon already installed (version: ${version}, state: ${state})"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Installing TeslaMate addon..."
|
log "Installing ${ADDON_NAME} addon..."
|
||||||
supervisor_api_post "/addons/${TESLAMATE_SLUG}/install" > /dev/null
|
supervisor_api POST "/addons/${ADDON_SLUG}/install" > /dev/null
|
||||||
|
|
||||||
# Wait for installation to complete (the TeslaMate image is large)
|
# Wait for installation to complete (the TeslaMate image is large)
|
||||||
log "Waiting for TeslaMate installation..."
|
log "Waiting for ${ADDON_NAME} installation..."
|
||||||
local max_attempts=120
|
local max_attempts=120
|
||||||
local attempt=0
|
local attempt=0
|
||||||
while [ "$attempt" -lt "$max_attempts" ]; do
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
||||||
version=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.version // empty')
|
version=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.version // empty')
|
||||||
if [ -n "$version" ]; then
|
if [ -n "$version" ]; then
|
||||||
ok "TeslaMate addon installed (version: ${version})"
|
ok "${ADDON_NAME} addon installed (version: ${version})"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
debug "Attempt $((attempt + 1))/${max_attempts}: TeslaMate not installed yet"
|
|
||||||
sleep 5
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
err "TeslaMate addon installation timed out"
|
err "${ADDON_NAME} addon installation timed out"
|
||||||
}
|
}
|
||||||
|
|
||||||
configure_and_start_teslamate() {
|
configure_and_start_addon() {
|
||||||
local postgres_slug="$1"
|
local postgres_slug="$1"
|
||||||
|
|
||||||
# Derive the postgres hostname from its slug (underscores -> hyphens)
|
# Derive the postgres hostname from its slug (underscores → hyphens)
|
||||||
local postgres_host
|
local postgres_host
|
||||||
postgres_host=$(echo "$postgres_slug" | tr '_' '-')
|
postgres_host=$(echo "$postgres_slug" | tr '_' '-')
|
||||||
|
|
||||||
log "Configuring TeslaMate addon (db host: ${postgres_host})..."
|
log "Configuring ${ADDON_NAME} 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
|
local current_options overrides options_json
|
||||||
current_options=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.options')
|
current_options=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.options')
|
||||||
overrides=$(jq -n \
|
overrides=$(jq -n \
|
||||||
--arg user "$POSTGRES_USER" \
|
--arg user "$POSTGRES_USER" \
|
||||||
--arg pass "$POSTGRES_PASSWORD" \
|
--arg pass "$POSTGRES_PASSWORD" \
|
||||||
@@ -480,122 +416,89 @@ configure_and_start_teslamate() {
|
|||||||
--arg tz "$TIMEZONE" \
|
--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}')
|
'{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)}')
|
options_json=$(jq -n --argjson cur "$current_options" --argjson ovr "$overrides" '{options: ($cur + $ovr)}')
|
||||||
debug "options payload: ${options_json}"
|
|
||||||
|
|
||||||
supervisor_api_post "/addons/${TESLAMATE_SLUG}/options" "${options_json}" > /dev/null
|
supervisor_api_with_body POST "/addons/${ADDON_SLUG}/options" \
|
||||||
|
"${options_json}" > /dev/null
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
local state
|
local state
|
||||||
state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state')
|
state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state')
|
||||||
|
|
||||||
if [ "$state" = "started" ]; then
|
if [ "$state" = "started" ]; then
|
||||||
ok "TeslaMate addon already running"
|
ok "${ADDON_NAME} addon already running"
|
||||||
wait_for_teslamate_ready
|
wait_for_addon_ready
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Starting TeslaMate addon..."
|
log "Starting ${ADDON_NAME} addon..."
|
||||||
supervisor_api_post "/addons/${TESLAMATE_SLUG}/start" > /dev/null
|
supervisor_api POST "/addons/${ADDON_SLUG}/start" > /dev/null
|
||||||
|
|
||||||
# Wait for addon state to become "started"
|
# Wait for addon state to become "started"
|
||||||
log "Waiting for TeslaMate addon to start..."
|
log "Waiting for ${ADDON_NAME} addon to start..."
|
||||||
local max_attempts=60
|
local max_attempts=60
|
||||||
local attempt=0
|
local attempt=0
|
||||||
while [ "$attempt" -lt "$max_attempts" ]; do
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
||||||
state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state')
|
state=$(supervisor_api_jq "/addons/${ADDON_SLUG}/info" '.data.state')
|
||||||
if [ "$state" = "started" ]; then
|
if [ "$state" = "started" ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if [ "$state" = "error" ]; then
|
|
||||||
dump_diagnostics 50
|
|
||||||
err "TeslaMate addon failed to start (state: ${state})"
|
|
||||||
fi
|
|
||||||
debug "Attempt $((attempt + 1))/${max_attempts}: TeslaMate state=${state}"
|
|
||||||
sleep 5
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
if [ "$state" != "started" ]; then
|
if [ "$state" != "started" ]; then
|
||||||
dump_diagnostics 50
|
dump_addon_logs "${ADDON_SLUG}"
|
||||||
err "TeslaMate addon did not start in time (state: ${state})"
|
err "${ADDON_NAME} addon did not start in time (state: ${state})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
wait_for_teslamate_ready
|
wait_for_addon_ready
|
||||||
ok "TeslaMate addon started and ready"
|
ok "${ADDON_NAME} addon started and ready"
|
||||||
}
|
}
|
||||||
|
|
||||||
# dump_diagnostics() prints PostgreSQL and TeslaMate add-on logs (full lines) so
|
wait_for_addon_ready() {
|
||||||
# a startup failure shows its real cause. PostgreSQL is reported first because
|
log "Waiting for ${ADDON_NAME} to be ready (listening on port ${ADDON_PORT})..."
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_teslamate_ready() {
|
|
||||||
log "Waiting for TeslaMate to be ready (listening on port ${TESLAMATE_PORT})..."
|
|
||||||
local max_attempts=60
|
local max_attempts=60
|
||||||
local attempt=0
|
local attempt=0
|
||||||
local logs state
|
|
||||||
while [ "$attempt" -lt "$max_attempts" ]; do
|
while [ "$attempt" -lt "$max_attempts" ]; do
|
||||||
logs=$(supervisor_api GET "/addons/${TESLAMATE_SLUG}/logs" 2>/dev/null || true)
|
local logs
|
||||||
if echo "$logs" | grep -q "TeslaMateWeb.Endpoint"; then
|
logs=$(supervisor_api GET "/addons/${ADDON_SLUG}/logs" 2>/dev/null || true)
|
||||||
ok "TeslaMate is ready"
|
if echo "$logs" | grep -qF "$ADDON_READY_LOG"; then
|
||||||
|
ok "${ADDON_NAME} is ready"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# TeslaMate's s6 finish script halts the whole container if the service
|
|
||||||
# exits non-zero, so a crash during startup shows up as the add-on leaving
|
|
||||||
# the "started" state. Detect that and fail immediately with the real logs
|
|
||||||
# instead of polling uselessly until the timeout.
|
|
||||||
state=$(supervisor_api_jq "/addons/${TESLAMATE_SLUG}/info" '.data.state')
|
|
||||||
if [ "$state" = "error" ] || [ "$state" = "stopped" ]; then
|
|
||||||
dump_diagnostics 50
|
|
||||||
err "TeslaMate crashed during startup (add-on state: ${state})"
|
|
||||||
fi
|
|
||||||
|
|
||||||
debug "Attempt $((attempt + 1))/${max_attempts}: endpoint not up yet (state=${state})"
|
|
||||||
sleep 5
|
sleep 5
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
dump_diagnostics 50
|
dump_addon_logs "${ADDON_SLUG}"
|
||||||
err "TeslaMate did not become ready in time (never saw 'TeslaMateWeb.Endpoint' in logs)"
|
err "${ADDON_NAME} did not become ready in time (never saw '${ADDON_READY_LOG}' in logs)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Main ---
|
# --- Main ---
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
log "Setting up Home Assistant for TeslaMate development"
|
log "Setting up Home Assistant for ${ADDON_NAME} development"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
debug "Verbose debug output enabled"
|
wait_for_ha
|
||||||
debug "HA_URL=${HA_URL} POSTGRES_SLUG=${POSTGRES_SLUG} TESLAMATE_SLUG=${TESLAMATE_SLUG}"
|
complete_onboarding
|
||||||
|
add_addon_repo
|
||||||
|
|
||||||
run_step "Wait for Home Assistant" wait_for_ha
|
install_postgres "$POSTGRES_SLUG"
|
||||||
run_step "Complete onboarding" complete_onboarding
|
configure_and_start_postgres "$POSTGRES_SLUG"
|
||||||
run_step "Add addon repository" add_addon_repo
|
install_addon
|
||||||
|
configure_and_start_addon "$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 ""
|
echo ""
|
||||||
ok "Setup complete!"
|
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 " 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 " PostgreSQL: ${POSTGRES_SLUG} (user: ${POSTGRES_USER}, db: ${POSTGRES_DB})"
|
||||||
log " TeslaMate: open the TeslaMate panel via HA ingress (${HA_URL})"
|
log " ${ADDON_NAME}: accessible via Home Assistant ingress"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ on:
|
|||||||
- Dockerfile
|
- Dockerfile
|
||||||
- rootfs/**
|
- rootfs/**
|
||||||
- .devcontainer/**
|
- .devcontainer/**
|
||||||
|
# Home Assistant and the third-party add-on repository this test installs both
|
||||||
|
# move independently of this repo, so the test can break with no change here.
|
||||||
|
# Run it on a schedule to catch that on main rather than on an unrelated pull
|
||||||
|
# request that happens to touch one of the paths above.
|
||||||
|
schedule:
|
||||||
|
- cron: "17 6 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
integration:
|
integration:
|
||||||
@@ -30,20 +37,22 @@ jobs:
|
|||||||
- name: Run integration test in devcontainer
|
- name: Run integration test in devcontainer
|
||||||
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
|
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
|
||||||
with:
|
with:
|
||||||
env: |
|
runCmd: bash .devcontainer/run-integration-test.sh stable
|
||||||
DEBUG=${{ vars.DEBUG }}
|
|
||||||
GITHUB_ACTIONS=true
|
|
||||||
runCmd: |
|
|
||||||
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
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '#!/bin/sh\nexit 0\n' | sudo tee /usr/sbin/apparmor_parser > /dev/null && sudo chmod +x /usr/sbin/apparmor_parser
|
# Early warning for Home Assistant changes that have not reached stable yet.
|
||||||
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
|
# Deliberately never runs on a pull request, so a broken beta cannot block a
|
||||||
if [ -n "${DEBUG:-}" ]; then
|
# merge, and is non-blocking when it does run.
|
||||||
supervisor_run &
|
canary:
|
||||||
else
|
name: Integration test (beta)
|
||||||
supervisor_run > /dev/null 2>&1 &
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||||
fi
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
bash .devcontainer/setup-ha.sh
|
- name: Run integration test in devcontainer
|
||||||
|
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
|
||||||
|
with:
|
||||||
|
runCmd: bash .devcontainer/run-integration-test.sh beta
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"init": false,
|
"init": false,
|
||||||
"panel_icon": "mdi:car-connected",
|
"panel_icon": "mdi:car-connected",
|
||||||
"panel_title": "TeslaMate",
|
"panel_title": "TeslaMate",
|
||||||
|
"ports": {
|
||||||
|
"4000/tcp": null
|
||||||
|
},
|
||||||
|
"ports_description": {
|
||||||
|
"4000/tcp": "Web interface (not required for Home Assistant ingress)"
|
||||||
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"database_user": null,
|
"database_user": null,
|
||||||
"database_pass": null,
|
"database_pass": null,
|
||||||
|
|||||||
Reference in New Issue
Block a user