Add HomeCore Updater v1.0

This commit is contained in:
HomeCore Admin 2026-09-16 13:49:09 +00:00
parent 9ef3b1d279
commit 56831c699f
7 changed files with 421 additions and 0 deletions

View File

@ -0,0 +1,13 @@
FROM ghcr.io/home-assistant/base:latest
ARG BUILD_VERSION
ARG BUILD_ARCH
LABEL io.hass.version="$BUILD_VERSION" io.hass.type="app" io.hass.arch="$BUILD_ARCH"
RUN apk add --no-cache python3 py3-pip && python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY app /opt/homecore-updater
COPY run.sh /run.sh
RUN chmod a+x /run.sh
WORKDIR /opt/homecore-updater
CMD ["/run.sh"]

View File

@ -0,0 +1,10 @@
# HomeCore Updater
Internal HomeCore maintenance app.
It has no customer UI. It watches `/share/homecore/update-request.json` for
locally signed requests from HomeCore Controller and performs the requested
HomeCore Controller update through Home Assistant Supervisor.
Automatic rollout is not supported. HomeCore Admin must create each update
request for a specific controller.

Binary file not shown.

View File

@ -0,0 +1,374 @@
from pathlib import Path
import hashlib
import hmac
import json
import os
import secrets
import time
import httpx
VERSION = "1.0.0"
SHARE = Path("/share/homecore")
CONFIG = Path("/config")
SECRET_LOCAL = CONFIG / "updater-secret"
SECRET_SHARED = SHARE / "updater-secret"
REQUEST_FILE = SHARE / "update-request.json"
RESULT_FILE = SHARE / "update-result.json"
STATE_FILE = CONFIG / "updater-state.json"
SUPERVISOR = "http://supervisor"
SHARE.mkdir(parents=True, exist_ok=True)
CONFIG.mkdir(parents=True, exist_ok=True)
def atomic_write(path: Path, data: bytes):
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_bytes(data)
os.replace(tmp, path)
def canonical(data):
return json.dumps(
data,
sort_keys=True,
separators=(",", ":"),
).encode()
def ensure_secret():
if SECRET_LOCAL.exists():
secret = SECRET_LOCAL.read_text().strip()
else:
secret = secrets.token_hex(32)
SECRET_LOCAL.write_text(secret + "\n")
os.chmod(SECRET_LOCAL, 0o600)
atomic_write(SECRET_SHARED, (secret + "\n").encode())
try:
os.chmod(SECRET_SHARED, 0o600)
except Exception:
pass
return secret.encode()
SECRET = ensure_secret()
def load_json(path):
try:
return json.loads(path.read_text())
except Exception:
return {}
def save_json(path, data):
atomic_write(
path,
(json.dumps(data, indent=2, sort_keys=True) + "\n").encode(),
)
def supervisor_headers():
token = os.getenv("SUPERVISOR_TOKEN")
if not token:
raise RuntimeError("SUPERVISOR_TOKEN is not available")
return {
"Authorization": "Bearer " + token,
"Content-Type": "application/json",
}
def supervisor_request(method, path, payload=None, timeout=120):
r = httpx.request(
method,
SUPERVISOR + path,
headers=supervisor_headers(),
json=payload,
timeout=timeout,
)
r.raise_for_status()
try:
body = r.json()
except Exception:
return {}
if isinstance(body, dict):
if body.get("result") == "error":
raise RuntimeError(body.get("message") or "Supervisor error")
if "data" in body:
return body["data"]
return body
def find_homecore_controller():
data = supervisor_request("GET", "/store/addons", timeout=30)
apps = data.get("addons", []) if isinstance(data, dict) else (data or [])
candidates = []
for app in apps:
slug = str(app.get("slug", ""))
name = str(app.get("name", ""))
if (
name == "HomeCore Controller"
or slug == "homecore_controller"
or slug.endswith("_homecore_controller")
):
candidates.append(app)
if not candidates:
raise RuntimeError(
"HomeCore Controller was not found in the Home Assistant App store"
)
installed = [x for x in candidates if x.get("installed")]
return (installed or candidates)[0]
def sign_result(body):
sig = hmac.new(SECRET, canonical(body), hashlib.sha256).hexdigest()
return {**body, "signature": sig}
def write_result(status, request, detail=""):
body = {
"schema": 1,
"request_id": str(request.get("request_id", "")),
"target_version": str(request.get("target_version", "")),
"status": status,
"detail": detail,
"updated_at": int(time.time()),
"updater_version": VERSION,
}
save_json(RESULT_FILE, sign_result(body))
print(
"HOMECORE UPDATER:"
f" {status}"
f" request={body['request_id'] or '-'}"
f" target={body['target_version'] or '-'}"
f" {detail}",
flush=True,
)
def verify_request(request):
if not isinstance(request, dict):
raise RuntimeError("Update request is not valid JSON")
signature = str(request.get("signature", "")).strip()
if not signature:
raise RuntimeError("Update request signature is missing")
body = {k: v for k, v in request.items() if k != "signature"}
expected = hmac.new(
SECRET,
canonical(body),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise RuntimeError("Update request signature is invalid")
if int(body.get("schema", 0)) != 1:
raise RuntimeError("Unsupported update request schema")
request_id = str(body.get("request_id", "")).strip()
target = str(body.get("target_version", "")).strip()
controller_id = str(body.get("controller_id", "")).strip()
if not request_id or not target or not controller_id:
raise RuntimeError("Update request is incomplete")
age = int(time.time()) - int(body.get("issued_at", 0) or 0)
if age < -120 or age > 900:
raise RuntimeError("Update request has expired")
return body
def installed_version(app):
value = app.get("version")
return str(value or "")
def repository_version(app):
return str(
app.get("version_latest")
or app.get("version_available")
or app.get("version")
or ""
)
def perform_update(request):
request_id = request["request_id"]
target = request["target_version"]
write_result("preparing", request, "Manual update accepted by HomeCore Updater")
supervisor_request("POST", "/store/reload", timeout=60)
time.sleep(3)
app = find_homecore_controller()
slug = str(app.get("slug", "")).strip()
installed = installed_version(app)
latest = repository_version(app)
print(
"HOMECORE UPDATER: repository check"
f" installed={installed or '-'}"
f" latest={latest or '-'}"
f" target={target}",
flush=True,
)
if not slug:
raise RuntimeError("HomeCore Controller slug could not be determined")
if latest != target:
raise RuntimeError(
"Home Assistant repository currently offers "
+ (latest or "no version")
+ "; target is "
+ target
)
if installed == target:
write_result(
"completed",
request,
"HomeCore Controller already has the requested version",
)
return
supervisor_request("GET", f"/store/addons/{slug}/availability", timeout=60)
write_result(
"installing",
request,
"Backup requested; Supervisor is updating HomeCore Controller",
)
# This app is deliberately separate from HomeCore Controller, so this is
# not a self-update. Supervisor is allowed to update the other app.
supervisor_request(
"POST",
f"/store/addons/{slug}/update",
{
"backup": True,
"background": True,
},
timeout=120,
)
deadline = time.time() + 600
last_seen = ""
while time.time() < deadline:
time.sleep(5)
supervisor_request("POST", "/store/reload", timeout=60)
app = find_homecore_controller()
current = installed_version(app)
if current != last_seen:
print(
"HOMECORE UPDATER: waiting for controller"
f" current={current or '-'} target={target}",
flush=True,
)
last_seen = current
if current == target:
write_result(
"completed",
request,
"HomeCore Controller update completed successfully",
)
return
raise RuntimeError(
"Timed out waiting for HomeCore Controller to reach " + target
)
def main():
print(
"HOMECORE UPDATER:"
f" version={VERSION}"
" supervisor_token="
+ ("available" if os.getenv("SUPERVISOR_TOKEN") else "missing"),
flush=True,
)
print(
"HOMECORE UPDATER: waiting for signed requests in "
+ str(REQUEST_FILE),
flush=True,
)
last_seen_request = ""
while True:
try:
if REQUEST_FILE.exists():
raw = load_json(REQUEST_FILE)
req_id = str(raw.get("request_id", ""))
if req_id and req_id != last_seen_request:
request = verify_request(raw)
state = load_json(STATE_FILE)
if str(state.get("request_id", "")) == req_id:
# Never execute an identical request twice.
last_seen_request = req_id
else:
save_json(
STATE_FILE,
{
"request_id": req_id,
"target_version": request["target_version"],
"status": "running",
"started_at": int(time.time()),
},
)
try:
perform_update(request)
save_json(
STATE_FILE,
{
"request_id": req_id,
"target_version": request["target_version"],
"status": "completed",
"completed_at": int(time.time()),
},
)
except Exception as exc:
write_result("failed", request, str(exc))
save_json(
STATE_FILE,
{
"request_id": req_id,
"target_version": request["target_version"],
"status": "failed",
"detail": str(exc),
"failed_at": int(time.time()),
},
)
last_seen_request = req_id
except Exception as exc:
print("HOMECORE UPDATER: watcher error:", exc, flush=True)
time.sleep(2)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,20 @@
name: HomeCore Updater
version: "1.0.0"
slug: homecore_updater
description: HomeCore maintenance service for manual per-controller updates.
url: https://homecore.home-core.co.uk
init: false
arch: [aarch64]
hassio_api: true
hassio_role: admin
startup: services
stage: experimental
backup: hot
boot: auto
options: {}
schema: {}
map:
- type: addon_config
read_only: false
- type: share
read_only: false

View File

@ -0,0 +1 @@
httpx==0.28.1

3
homecore_updater/run.sh Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/with-contenv sh
set -eu
exec python /opt/homecore-updater/main.py