HomeCore Controller v0.1.5 manual per-controller updates

This commit is contained in:
HomeCore 2026-09-16 12:31:39 +00:00
parent 5d35543444
commit 9778115297
4 changed files with 135 additions and 155 deletions

View File

@ -1,5 +1,14 @@
# HomeCore Controller Changelog # HomeCore Controller Changelog
## 0.1.5
- Changed HomeCore updates to manual per-controller deployment from HomeCore Admin.
- Removed global automatic release polling.
- Controllers now accept only update commands explicitly addressed to their controller ID.
- Failed updates do not retry automatically.
- Home Assistant backup remains requested before each controller update.
- Preserves the 0.1.4 Home Assistant Ingress commissioning fix.
## 0.1.4 ## 0.1.4
- Fixed Home Assistant Ingress commissioning form routing. - Fixed Home Assistant Ingress commissioning form routing.

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.4" VERSION = "0.1.5"
DATA = Path("/config") DATA = Path("/config")
DATA.mkdir(parents=True, exist_ok=True) DATA.mkdir(parents=True, exist_ok=True)

View File

@ -9,7 +9,6 @@ import httpx
UPDATE_STATE = Path("/config/homecore_update.json") UPDATE_STATE = Path("/config/homecore_update.json")
SUPERVISOR = "http://supervisor" SUPERVISOR = "http://supervisor"
@ -26,19 +25,13 @@ def load_update_state():
def save_update_state(data): def save_update_state(data):
UPDATE_STATE.write_text( UPDATE_STATE.write_text(json.dumps(data, indent=2))
json.dumps(data, indent=2)
)
def supervisor_headers(): def supervisor_headers():
token = os.getenv("SUPERVISOR_TOKEN") token = os.getenv("SUPERVISOR_TOKEN")
if not token: if not token:
raise RuntimeError( raise RuntimeError("SUPERVISOR_TOKEN is not available")
"SUPERVISOR_TOKEN is not available"
)
return { return {
"Authorization": "Bearer " + token, "Authorization": "Bearer " + token,
"Content-Type": "application/json", "Content-Type": "application/json",
@ -53,7 +46,6 @@ def supervisor_request(method, path, payload=None, timeout=90):
json=payload, json=payload,
timeout=timeout, timeout=timeout,
) )
r.raise_for_status() r.raise_for_status()
try: try:
@ -63,10 +55,7 @@ def supervisor_request(method, path, payload=None, timeout=90):
if isinstance(body, dict): if isinstance(body, dict):
if body.get("result") == "error": if body.get("result") == "error":
raise RuntimeError( raise RuntimeError(body.get("message") or "Supervisor error")
body.get("message") or "Supervisor error"
)
if "data" in body: if "data" in body:
return body["data"] return body["data"]
@ -74,10 +63,7 @@ def supervisor_request(method, path, payload=None, timeout=90):
def find_homecore_app(): def find_homecore_app():
data = supervisor_request( data = supervisor_request("GET", "/store/addons")
"GET",
"/store/addons",
)
if isinstance(data, dict): if isinstance(data, dict):
apps = data.get("addons", []) apps = data.get("addons", [])
@ -85,11 +71,9 @@ def find_homecore_app():
apps = data or [] apps = data or []
candidates = [] candidates = []
for app in apps: for app in apps:
slug = str(app.get("slug", "")) slug = str(app.get("slug", ""))
name = str(app.get("name", "")) name = str(app.get("name", ""))
if ( if (
name == "HomeCore Controller" name == "HomeCore Controller"
or slug == "homecore_controller" or slug == "homecore_controller"
@ -102,15 +86,19 @@ def find_homecore_app():
"HomeCore Controller was not found in the Home Assistant App store" "HomeCore Controller was not found in the Home Assistant App store"
) )
installed = [ installed = [x for x in candidates if x.get("installed")]
x for x in candidates
if x.get("installed")
]
return (installed or candidates)[0] return (installed or candidates)[0]
class HomeCoreUpdateManager: 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__( def __init__(
self, self,
@ -123,56 +111,39 @@ class HomeCoreUpdateManager:
self.cloud_api_fn = cloud_api_fn self.cloud_api_fn = cloud_api_fn
self.load_controller_state = load_controller_state self.load_controller_state = load_controller_state
self.controller_session_fn = controller_session_fn self.controller_session_fn = controller_session_fn
self.last_report = None self.last_report = None
def cloud_api(self): def cloud_api(self):
return self.cloud_api_fn()["cloud_api"].rstrip("/") return self.cloud_api_fn()["cloud_api"].rstrip("/")
def report(self, status, request_id="", target_version="", detail=""):
def report( key = (status, request_id, target_version, detail)
self,
status,
target_version="",
detail="",
):
key = (
status,
target_version,
detail,
)
if key == self.last_report: if key == self.last_report:
return return
self.last_report = key self.last_report = key
print( print(
f"HOMECORE UPDATE: {status} " f"HOMECORE UPDATE: {status} "
f"request={request_id or '-'} "
f"current={self.current_version} " f"current={self.current_version} "
f"target={target_version} " f"target={target_version or '-'} "
f"{detail}", f"{detail}",
flush=True, flush=True,
) )
state = self.load_controller_state() state = self.load_controller_state()
controller_id = state.get("controller_id") controller_id = state.get("controller_id")
if not controller_id: if not controller_id:
return return
try: try:
token = self.controller_session_fn() token = self.controller_session_fn()
httpx.post( httpx.post(
self.cloud_api() self.cloud_api()
+ f"/api/v1/controllers/{controller_id}/update-status", + f"/api/v1/controllers/{controller_id}/update-status",
headers={ headers={"Authorization": "Bearer " + token},
"Authorization": "Bearer " + token
},
json={ json={
"request_id": request_id,
"status": status, "status": status,
"target_version": target_version, "target_version": target_version,
"current_version": self.current_version, "current_version": self.current_version,
@ -180,159 +151,159 @@ class HomeCoreUpdateManager:
}, },
timeout=15, timeout=15,
).raise_for_status() ).raise_for_status()
except Exception as exc: except Exception as exc:
print( print("HOMECORE UPDATE: status report failed:", exc, flush=True)
"HOMECORE UPDATE: status report failed:",
exc,
flush=True,
)
def update_command(self):
def desired_release(self): state = self.load_controller_state()
r = httpx.get( controller_id = state.get("controller_id")
self.cloud_api() if not controller_id:
+ "/api/v1/releases/latest",
params={
"channel": "stable",
},
timeout=15,
)
if r.status_code == 404:
return None 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() r.raise_for_status()
body = r.json()
return r.json() return body.get("command") if isinstance(body, dict) else None
def mark_completed_if_required(self): def mark_completed_if_required(self):
st = load_update_state() st = load_update_state()
request_id = str(st.get("request_id", ""))
target = st.get("target_version", "") target = str(st.get("target_version", ""))
if ( if (
target request_id
and target
and target == self.current_version and target == self.current_version
and st.get("status") == "installing" and st.get("status") == "installing"
): ):
self.report( self.report(
"completed", "completed",
request_id,
target, target,
"HomeCore Controller update completed successfully", "HomeCore Controller update completed successfully",
) )
save_update_state( save_update_state(
{ {
**st,
"status": "completed", "status": "completed",
"target_version": target,
"completed_at": int(time.time()), "completed_at": int(time.time()),
} }
) )
def check_once(self): def check_once(self):
command = self.update_command()
desired = self.desired_release() if not command:
if not desired:
return return
target = str( request_id = str(command.get("request_id", "")).strip()
desired.get("version", "") target = str(command.get("target_version", "")).strip()
).strip() if not request_id or not target:
raise RuntimeError("HomeCore Cloud returned an incomplete update command")
if not target:
return
if version_tuple(target) <= version_tuple(
self.current_version
):
self.report(
"up_to_date",
target,
"Controller matches the approved HomeCore release",
)
return
previous = load_update_state() previous = load_update_state()
if ( if (
previous.get("target_version") == target str(previous.get("request_id", "")) == request_id
and previous.get("status") in ( and previous.get("status") == "installing"
"installing",
"failed",
)
): ):
attempted = int( # Supervisor already accepted this exact request. Wait for the app
previous.get("attempted_at", 0) # restart rather than ever issuing the update a second time.
) return
# Avoid update loops. if target == self.current_version:
if time.time() - attempted < 600: self.report(
return "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( self.report(
"checking", "preparing",
request_id,
target, target,
"Approved HomeCore release detected", "Manual HomeCore Admin update request received",
)
supervisor_request(
"POST",
"/store/reload",
) )
supervisor_request("POST", "/store/reload")
time.sleep(3) time.sleep(3)
app = find_homecore_app() app = find_homecore_app()
slug = str(app.get("slug", ""))
slug = app.get("slug")
repository_version = str( repository_version = str(
app.get("version") app.get("version")
or app.get("version_latest") or app.get("version_latest")
or app.get("version_available")
or "" or ""
) )
installed_version = str( installed_field = app.get("installed")
app.get("installed") installed_version = (
or self.current_version installed_field
if isinstance(installed_field, str)
else self.current_version
) )
if not slug: if not slug:
raise RuntimeError( raise RuntimeError("HomeCore App slug could not be determined")
"HomeCore App slug could not be determined"
)
if repository_version != target: if repository_version != target:
self.report( self.report(
"waiting_repository", "waiting_repository",
request_id,
target, target,
"Home Assistant repository currently offers " "Home Assistant repository currently offers "
+ (repository_version or "no version"), + (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 return
supervisor_request( supervisor_request("GET", f"/store/addons/{slug}/availability")
"GET",
f"/store/addons/{slug}/availability",
)
state = { state = {
"status": "installing", "status": "installing",
"request_id": request_id,
"target_version": target, "target_version": target,
"installed_version": installed_version, "installed_version": installed_version,
"repository_version": repository_version, "repository_version": repository_version,
"slug": slug, "slug": slug,
"attempted_at": int(time.time()), "attempted_at": int(time.time()),
} }
save_update_state(state) save_update_state(state)
self.report( self.report(
"installing", "installing",
request_id,
target, target,
"Backup requested; Supervisor update starting", "Backup requested; Supervisor update starting",
) )
@ -347,46 +318,46 @@ class HomeCoreUpdateManager:
) )
print( print(
"HOMECORE UPDATE: Supervisor accepted update job", "HOMECORE UPDATE: Supervisor accepted manual update job",
flush=True, flush=True,
) )
def run(self): def run(self):
self.mark_completed_if_required() self.mark_completed_if_required()
while True: while True:
try: try:
self.check_once() self.check_once()
except Exception as exc: except Exception as exc:
st = load_update_state() st = load_update_state()
request_id = str(st.get("request_id", ""))
target = str(st.get("target_version", ""))
target = st.get( # If the failure happened before state was written, attempt to
"target_version", # 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
save_update_state( failed_state = {
{ **st,
**st, "status": "failed",
"status": "failed", "request_id": request_id,
"target_version": target, "target_version": target,
"detail": str(exc), "detail": str(exc),
"attempted_at": int(time.time()), "failed_at": int(time.time()),
} }
) save_update_state(failed_state)
self.report("failed", request_id, target, str(exc))
self.report( # No automatic retry. The cloud suppresses a failed request;
"failed", # Admin must explicitly create a new request by pressing Install.
target,
str(exc),
)
time.sleep(60) 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.4" version: "0.1.5"
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