HomeCore Controller v0.1.17 staged from HomeCore Admin

This commit is contained in:
HomeCore Admin 2026-09-16 13:51:24 +00:00
parent 56831c699f
commit a72badc548
4 changed files with 183 additions and 257 deletions

View File

@ -1,5 +1,14 @@
# HomeCore Controller Changelog # HomeCore Controller Changelog
## 0.1.17
- Introduces the production HomeCore dual-app update architecture.
- HomeCore Controller no longer attempts to update itself.
- Admin update commands are locally authenticated and delegated to the separate HomeCore Updater app through `/share/homecore`.
- HomeCore Updater performs the Supervisor update while HomeCore Controller is replaced.
- The restarted Controller reports the Updater result back to HomeCore Admin.
- Automatic rollout remains disabled; every deployment is still initiated manually for one selected controller.
## 0.1.16 ## 0.1.16
- End-to-end validation release for the HomeCore Admin manual per-controller update path using Home Assistant Core's `hassio/update/addon` WebSocket command. - End-to-end validation release for the HomeCore Admin manual per-controller update path using Home Assistant Core's `hassio/update/addon` WebSocket command.

View File

@ -16,7 +16,7 @@ from cryptography.hazmat.primitives import serialization
import uvicorn import uvicorn
from updater import start_update_manager from updater import start_update_manager
VERSION = "0.1.16" VERSION = "0.1.17"
print( print(
"HOMECORE SUPERVISOR: token " "HOMECORE SUPERVISOR: token "

View File

@ -1,17 +1,23 @@
from pathlib import Path from pathlib import Path
import asyncio import hashlib
import hmac
import json import json
import os import os
import re import re
import secrets
import threading import threading
import time import time
import httpx import httpx
import websockets
UPDATE_STATE = Path("/config/homecore_update.json") UPDATE_STATE = Path("/config/homecore_update.json")
SUPERVISOR = "http://supervisor" SHARE = Path("/share/homecore")
SECRET_FILE = SHARE / "updater-secret"
REQUEST_FILE = SHARE / "update-request.json"
RESULT_FILE = SHARE / "update-result.json"
SHARE.mkdir(parents=True, exist_ok=True)
def version_tuple(value): def version_tuple(value):
@ -19,166 +25,82 @@ def version_tuple(value):
return tuple(int(x) for x in parts[:4]) return tuple(int(x) for x in parts[:4])
def load_update_state(): def canonical(data):
return json.dumps(
data,
sort_keys=True,
separators=(",", ":"),
).encode()
def load_json(path):
try: try:
return json.loads(UPDATE_STATE.read_text()) return json.loads(path.read_text())
except Exception: except Exception:
return {} return {}
def save_json(path, data):
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
os.replace(tmp, path)
def load_update_state():
return load_json(UPDATE_STATE)
def save_update_state(data): def save_update_state(data):
UPDATE_STATE.write_text(json.dumps(data, indent=2)) save_json(UPDATE_STATE, data)
def supervisor_headers(): def read_secret():
token = os.getenv("SUPERVISOR_TOKEN") if not SECRET_FILE.exists():
if not token: raise RuntimeError(
raise RuntimeError("SUPERVISOR_TOKEN is not available") "HomeCore Updater is not installed or is not ready"
return { )
"Authorization": "Bearer " + token,
"Content-Type": "application/json", secret = SECRET_FILE.read_text().strip()
} if len(secret) < 32:
raise RuntimeError("HomeCore Updater shared secret is invalid")
return secret.encode()
def supervisor_request(method, path, payload=None, timeout=90): def sign_body(body):
r = httpx.request( secret = read_secret()
method, return hmac.new(
SUPERVISOR + path, secret,
headers=supervisor_headers(), canonical(body),
json=payload, hashlib.sha256,
timeout=timeout, ).hexdigest()
)
r.raise_for_status()
def verify_signed(data):
if not isinstance(data, dict):
return False
signature = str(data.get("signature", "")).strip()
if not signature:
return False
body = {k: v for k, v in data.items() if k != "signature"}
try: try:
body = r.json() expected = sign_body(body)
except Exception: except Exception:
return {} return False
if isinstance(body, dict): return hmac.compare_digest(signature, expected)
if body.get("result") == "error":
raise RuntimeError(body.get("message") or "Supervisor error")
if "data" in body:
return body["data"]
return body
HOME_ASSISTANT_WEBSOCKET = "ws://supervisor/core/websocket"
async def _homeassistant_update_app_async(slug, backup=True):
token = os.getenv("SUPERVISOR_TOKEN")
if not token:
raise RuntimeError("SUPERVISOR_TOKEN is not available")
async with websockets.connect(
HOME_ASSISTANT_WEBSOCKET,
open_timeout=20,
close_timeout=5,
ping_interval=None,
) as ws:
first = json.loads(await asyncio.wait_for(ws.recv(), timeout=20))
if first.get("type") != "auth_required":
raise RuntimeError(
"Unexpected Home Assistant WebSocket greeting: "
+ str(first.get("type"))
)
await ws.send(
json.dumps(
{
"type": "auth",
"access_token": token,
}
)
)
auth = json.loads(await asyncio.wait_for(ws.recv(), timeout=20))
if auth.get("type") != "auth_ok":
raise RuntimeError(
"Home Assistant WebSocket authentication failed: "
+ str(auth.get("message") or auth.get("type"))
)
command_id = 1
await ws.send(
json.dumps(
{
"id": command_id,
"type": "hassio/update/addon",
"addon": slug,
"backup": bool(backup),
}
)
)
result = json.loads(await asyncio.wait_for(ws.recv(), timeout=300))
if result.get("id") != command_id:
raise RuntimeError(
"Unexpected Home Assistant update response ID"
)
if not result.get("success"):
error = result.get("error") or {}
raise RuntimeError(
str(error.get("message") or error or "Home Assistant update failed")
)
def homeassistant_update_app(slug, backup=True):
"""
Ask Home Assistant Core to update this app.
Calling Supervisor's /store/addons/<slug>/update endpoint directly from
the app is blocked because an app cannot update itself. Home Assistant
Core exposes the admin WebSocket command hassio/update/addon specifically
for normal app updates. The Supervisor proxy authenticates this app using
SUPERVISOR_TOKEN.
"""
return asyncio.run(
_homeassistant_update_app_async(slug, backup=backup)
)
def find_homecore_app():
data = supervisor_request("GET", "/store/addons")
if isinstance(data, dict):
apps = data.get("addons", [])
else:
apps = 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]
class HomeCoreUpdateManager: class HomeCoreUpdateManager:
""" """
Manual, per-controller update manager. Manual, per-controller HomeCore update coordinator.
The manager never asks for a global/latest release. It polls only for a This controller never updates itself. HomeCore Admin sends a command to
command addressed to this controller. A command exists only after an this controller, which writes a locally authenticated request for the
administrator presses Install Update for that controller in HomeCore Admin. separate HomeCore Updater app. The Updater performs the Supervisor update.
Failed updates are not retried automatically.
""" """
def __init__( def __init__(
@ -252,49 +174,111 @@ class HomeCoreUpdateManager:
body = r.json() body = r.json()
return body.get("command") if isinstance(body, dict) else None return body.get("command") if isinstance(body, dict) else None
def mark_completed_if_required(self): def delegated_result(self):
data = load_json(RESULT_FILE)
if not data or not verify_signed(data):
return {}
return data
def process_result(self):
st = load_update_state() st = load_update_state()
request_id = str(st.get("request_id", "")) request_id = str(st.get("request_id", ""))
target = str(st.get("target_version", "")) target = str(st.get("target_version", ""))
if ( if not request_id or not target:
request_id return False
and target
and target == self.current_version result = self.delegated_result()
and st.get("status") == "installing" if str(result.get("request_id", "")) != request_id:
): return False
self.report(
"completed", status = str(result.get("status", ""))
request_id, detail = str(result.get("detail", ""))
target,
"HomeCore Controller update completed successfully", if status == "failed":
) self.report("failed", request_id, target, detail)
save_update_state( save_update_state(
{ {
**st, **st,
"status": "completed", "status": "failed",
"completed_at": int(time.time()), "detail": detail,
"failed_at": int(time.time()),
} }
) )
return True
if status == "installing":
self.report("installing", request_id, target, detail)
return True
if status == "completed":
if self.current_version == target:
self.report(
"completed",
request_id,
target,
detail or "HomeCore Controller update completed successfully",
)
save_update_state(
{
**st,
"status": "completed",
"completed_at": int(time.time()),
}
)
return True
# The Updater has completed replacement but this old process may
# still be exiting. The new controller version will report success.
return True
return False
def delegate(self, request_id, target):
state = self.load_controller_state()
controller_id = str(state.get("controller_id", "")).strip()
if not controller_id:
raise RuntimeError("Controller is not bound")
body = {
"schema": 1,
"request_id": request_id,
"target_version": target,
"current_version": self.current_version,
"controller_id": controller_id,
"issued_at": int(time.time()),
"nonce": secrets.token_hex(16),
}
body["signature"] = sign_body(body)
save_json(REQUEST_FILE, body)
print(
"HOMECORE UPDATE: delegated to HomeCore Updater "
f"request={request_id} target={target}",
flush=True,
)
def check_once(self): def check_once(self):
self.process_result()
command = self.update_command() command = self.update_command()
if not command: if not command:
return return
request_id = str(command.get("request_id", "")).strip() request_id = str(command.get("request_id", "")).strip()
target = str(command.get("target_version", "")).strip() target = str(command.get("target_version", "")).strip()
if not request_id or not target: if not request_id or not target:
raise RuntimeError("HomeCore Cloud returned an incomplete update command") raise RuntimeError("HomeCore Cloud returned an incomplete update command")
previous = load_update_state() previous = load_update_state()
if (
str(previous.get("request_id", "")) == request_id if str(previous.get("request_id", "")) == request_id:
and previous.get("status") == "installing" status = str(previous.get("status", ""))
): if status in ("delegated", "installing", "completed", "failed"):
# Supervisor already accepted this exact request. Wait for the app # Never execute the same Admin request twice.
# restart rather than ever issuing the update a second time. self.process_result()
return return
if target == self.current_version: if target == self.current_version:
self.report( self.report(
@ -328,99 +312,35 @@ class HomeCoreUpdateManager:
"Manual HomeCore Admin update request received", "Manual HomeCore Admin update request received",
) )
supervisor_request("POST", "/store/reload") # This proves the separate updater is installed before we acknowledge
time.sleep(3) # the request as delegated.
read_secret()
app = find_homecore_app() self.delegate(request_id, target)
slug = str(app.get("slug", ""))
# Home Assistant Supervisor reports: save_update_state(
# version = installed app version {
# version_latest = newest version offered by the repository "status": "delegated",
# "request_id": request_id,
# Always compare the Admin target against version_latest first. "target_version": target,
repository_version = str( "delegated_at": int(time.time()),
app.get("version_latest") }
or app.get("version_available")
or app.get("version")
or ""
) )
print(
"HOMECORE UPDATE: repository check "
f"installed={app.get('version') or '-'} "
f"latest={app.get('version_latest') or '-'} "
f"target={target}",
flush=True,
)
installed_field = app.get("installed")
installed_version = (
installed_field
if isinstance(installed_field, str)
else self.current_version
)
if not slug:
raise RuntimeError("HomeCore App slug could not be determined")
if repository_version != target:
self.report(
"waiting_repository",
request_id,
target,
"Home Assistant repository currently offers "
+ (repository_version or "no version"),
)
save_update_state(
{
"status": "waiting_repository",
"request_id": request_id,
"target_version": target,
"repository_version": repository_version,
"checked_at": int(time.time()),
}
)
return
supervisor_request("GET", f"/store/addons/{slug}/availability")
state = {
"status": "installing",
"request_id": request_id,
"target_version": target,
"installed_version": installed_version,
"repository_version": repository_version,
"slug": slug,
"attempted_at": int(time.time()),
}
save_update_state(state)
self.report( self.report(
"installing", "installing",
request_id, request_id,
target, target,
"Backup requested; Home Assistant Core app update starting", "Handed to HomeCore Updater; backup and install pending",
)
print(
"HOMECORE UPDATE: Home Assistant WebSocket update "
f"slug={slug} target={target}",
flush=True,
)
# Home Assistant Supervisor blocks an app from calling its own
# /store/addons/<slug>/update endpoint directly. Route the request
# through Home Assistant Core's admin WebSocket command instead.
# Core then performs the normal Supervisor app update on our behalf.
homeassistant_update_app(slug, backup=True)
print(
"HOMECORE UPDATE: Home Assistant accepted manual app update",
flush=True,
) )
def run(self): def run(self):
self.mark_completed_if_required() # If this is the new version after a delegated update, report the
# Updater's completed result back to HomeCore Admin immediately.
try:
self.process_result()
except Exception as exc:
print("HOMECORE UPDATE: startup result check failed:", exc, flush=True)
while True: while True:
try: try:
@ -430,8 +350,6 @@ class HomeCoreUpdateManager:
request_id = str(st.get("request_id", "")) request_id = str(st.get("request_id", ""))
target = str(st.get("target_version", "")) target = str(st.get("target_version", ""))
# If the failure happened before state was written, attempt to
# recover the active command once for accurate reporting.
if not request_id: if not request_id:
try: try:
cmd = self.update_command() or {} cmd = self.update_command() or {}
@ -451,10 +369,7 @@ class HomeCoreUpdateManager:
save_update_state(failed_state) save_update_state(failed_state)
self.report("failed", request_id, target, str(exc)) self.report("failed", request_id, target, str(exc))
# No automatic retry. The cloud suppresses a failed request; time.sleep(10)
# Admin must explicitly create a new request by pressing Install.
time.sleep(30)
def start_update_manager( def start_update_manager(

View File

@ -1,5 +1,5 @@
name: HomeCore Controller name: HomeCore Controller
version: "0.1.16" version: "0.1.17"
slug: homecore_controller slug: homecore_controller
description: HomeCore local controller agent for Home Assistant Green. description: HomeCore local controller agent for Home Assistant Green.
url: https://homecore.home-core.co.uk url: https://homecore.home-core.co.uk
@ -23,3 +23,5 @@ schema:
map: map:
- type: addon_config - type: addon_config
read_only: false read_only: false
- type: share
read_only: false