from pathlib import Path import json import os import re import threading import time import httpx UPDATE_STATE = Path("/config/homecore_update.json") SUPERVISOR = "http://supervisor" def version_tuple(value): parts = re.findall(r"\d+", str(value)) return tuple(int(x) for x in parts[:4]) def load_update_state(): try: return json.loads(UPDATE_STATE.read_text()) except Exception: return {} def save_update_state(data): UPDATE_STATE.write_text(json.dumps(data, indent=2)) 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=90): 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 HOME_ASSISTANT_API = "http://supervisor/core/api" def homeassistant_request(method, path, payload=None, timeout=90): r = httpx.request( method, HOME_ASSISTANT_API + path, headers=supervisor_headers(), json=payload, timeout=timeout, ) r.raise_for_status() try: return r.json() except Exception: return {} def find_homecore_update_entity(current_version, target_version): states = homeassistant_request("GET", "/states", timeout=30) if not isinstance(states, list): raise RuntimeError("Home Assistant did not return entity states") candidates = [] for state in states: entity_id = str(state.get("entity_id", "")) if not entity_id.startswith("update."): continue attrs = state.get("attributes") or {} title = str(attrs.get("title") or "") friendly = str(attrs.get("friendly_name") or "") installed = str(attrs.get("installed_version") or "") latest = str(attrs.get("latest_version") or "") name_match = ( "homecore controller" in title.lower() or "homecore controller" in friendly.lower() ) version_match = ( installed == current_version and latest == target_version ) if name_match: candidates.append((state, version_match)) exact = [state for state, version_match in candidates if version_match] if exact: return exact[0] if len(candidates) == 1: return candidates[0][0] if not candidates: raise RuntimeError( "HomeCore Controller update entity was not found in Home Assistant" ) raise RuntimeError( "Multiple HomeCore Controller update entities were found; " "cannot choose safely" ) 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: """ Manual, per-controller update manager. The manager never asks for a global/latest release. It polls only for a command addressed to this controller. A command exists only after an administrator presses Install Update for that controller in HomeCore Admin. Failed updates are not retried automatically. """ def __init__( self, current_version, cloud_api_fn, load_controller_state, controller_session_fn, ): self.current_version = current_version self.cloud_api_fn = cloud_api_fn self.load_controller_state = load_controller_state self.controller_session_fn = controller_session_fn self.last_report = None def cloud_api(self): return self.cloud_api_fn()["cloud_api"].rstrip("/") def report(self, status, request_id="", target_version="", detail=""): key = (status, request_id, target_version, detail) if key == self.last_report: return self.last_report = key print( f"HOMECORE UPDATE: {status} " f"request={request_id or '-'} " f"current={self.current_version} " f"target={target_version or '-'} " f"{detail}", flush=True, ) state = self.load_controller_state() controller_id = state.get("controller_id") if not controller_id: return try: token = self.controller_session_fn() httpx.post( self.cloud_api() + f"/api/v1/controllers/{controller_id}/update-status", headers={"Authorization": "Bearer " + token}, json={ "request_id": request_id, "status": status, "target_version": target_version, "current_version": self.current_version, "detail": detail, }, timeout=15, ).raise_for_status() except Exception as exc: print("HOMECORE UPDATE: status report failed:", exc, flush=True) def update_command(self): state = self.load_controller_state() controller_id = state.get("controller_id") if not controller_id: return None token = self.controller_session_fn() r = httpx.get( self.cloud_api() + f"/api/v1/controllers/{controller_id}/update-command", headers={"Authorization": "Bearer " + token}, timeout=15, ) r.raise_for_status() body = r.json() return body.get("command") if isinstance(body, dict) else None def mark_completed_if_required(self): st = load_update_state() request_id = str(st.get("request_id", "")) target = str(st.get("target_version", "")) if ( request_id and target and target == self.current_version and st.get("status") == "installing" ): self.report( "completed", request_id, target, "HomeCore Controller update completed successfully", ) save_update_state( { **st, "status": "completed", "completed_at": int(time.time()), } ) def check_once(self): command = self.update_command() if not command: return request_id = str(command.get("request_id", "")).strip() target = str(command.get("target_version", "")).strip() if not request_id or not target: raise RuntimeError("HomeCore Cloud returned an incomplete update command") previous = load_update_state() if ( str(previous.get("request_id", "")) == request_id and previous.get("status") == "installing" ): # Supervisor already accepted this exact request. Wait for the app # restart rather than ever issuing the update a second time. return if target == self.current_version: self.report( "completed", request_id, target, "Controller already has the requested HomeCore version", ) save_update_state( { "status": "completed", "request_id": request_id, "target_version": target, "completed_at": int(time.time()), } ) return if version_tuple(target) < version_tuple(self.current_version): raise RuntimeError( "Automatic downgrade is not supported; controller has " + self.current_version + " and request targets " + target ) self.report( "preparing", request_id, target, "Manual HomeCore Admin update request received", ) supervisor_request("POST", "/store/reload") time.sleep(3) app = find_homecore_app() slug = str(app.get("slug", "")) # Home Assistant Supervisor reports: # version = installed app version # version_latest = newest version offered by the repository # # Always compare the Admin target against version_latest first. repository_version = str( 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( "installing", request_id, target, "Backup requested; Supervisor update starting", ) update_entity = find_homecore_update_entity( self.current_version, target, ) update_entity_id = str(update_entity.get("entity_id", "")).strip() if not update_entity_id: raise RuntimeError( "HomeCore Controller update entity ID is missing" ) print( "HOMECORE UPDATE: Home Assistant update action " f"entity={update_entity_id} target={target}", flush=True, ) # Home Assistant Supervisor deliberately blocks an app from calling # its own /store/addons//update endpoint. Ask Home Assistant Core # to execute the normal update.install action instead. Core performs # the Supervisor update, exactly as if an administrator installed the # app update from the Home Assistant UI. # # The state above is written before this call because updating this # app stops the current container. On restart, mark_completed_if_required # reports completion back to HomeCore Admin. homeassistant_request( "POST", "/services/update/install", { "entity_id": update_entity_id, "backup": True, }, timeout=300, ) print( "HOMECORE UPDATE: Home Assistant accepted manual update action", flush=True, ) def run(self): self.mark_completed_if_required() while True: try: self.check_once() except Exception as exc: st = load_update_state() request_id = str(st.get("request_id", "")) 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: try: cmd = self.update_command() or {} request_id = str(cmd.get("request_id", "")) target = str(cmd.get("target_version", "")) except Exception: pass failed_state = { **st, "status": "failed", "request_id": request_id, "target_version": target, "detail": str(exc), "failed_at": int(time.time()), } save_update_state(failed_state) self.report("failed", request_id, target, str(exc)) # No automatic retry. The cloud suppresses a failed request; # Admin must explicitly create a new request by pressing Install. time.sleep(30) def start_update_manager( current_version, cloud_api_fn, load_controller_state, controller_session_fn, ): manager = HomeCoreUpdateManager( current_version=current_version, cloud_api_fn=cloud_api_fn, load_controller_state=load_controller_state, controller_session_fn=controller_session_fn, ) threading.Thread( target=manager.run, daemon=True, name="homecore-update-manager", ).start()