HomeCore Controller v0.1.2 admin controlled updates

This commit is contained in:
HomeCore 2026-09-16 10:29:37 +00:00
parent 53374d7e82
commit 44caf5b355
4 changed files with 434 additions and 3 deletions

View File

@ -1,5 +1,19 @@
# HomeCore Controller Changelog
## 0.1.2
- HomeCore Admin is now the authority for production controller updates.
- Controllers poll only for Admin-approved HomeCore releases.
- Unpublished releases never install.
- Home Assistant App repository is refreshed automatically.
- The controller verifies that the repository version exactly matches the Admin-approved version.
- Supervisor creates a backup before HomeCore updates.
- Supervisor installs the approved HomeCore Controller release.
- Successful updates report back to HomeCore Admin.
- Failed updates report back to HomeCore Admin.
- Automatic retry protection prevents update loops.
- Home Assistant customer-facing update controls are no longer required for normal HomeCore updates.
## 0.1.1
- Controller setup can no longer create properties.

View File

@ -14,8 +14,9 @@ from fastapi.responses import HTMLResponse
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
import uvicorn
from updater import start_update_manager
VERSION = "0.1.1"
VERSION = "0.1.2"
DATA = Path("/config")
DATA.mkdir(parents=True, exist_ok=True)
@ -474,6 +475,13 @@ threading.Thread(
daemon=True,
).start()
start_update_manager(
current_version=VERSION,
cloud_api_fn=options,
load_controller_state=load_state,
controller_session_fn=controller_session,
)
if __name__ == "__main__":
uvicorn.run(
app,

View File

@ -0,0 +1,409 @@
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
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:
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,
target_version="",
detail="",
):
key = (
status,
target_version,
detail,
)
if key == self.last_report:
return
self.last_report = key
print(
f"HOMECORE UPDATE: {status} "
f"current={self.current_version} "
f"target={target_version} "
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={
"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 desired_release(self):
r = httpx.get(
self.cloud_api()
+ "/api/v1/releases/latest",
params={
"channel": "stable",
},
timeout=15,
)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def mark_completed_if_required(self):
st = load_update_state()
target = st.get("target_version", "")
if (
target
and target == self.current_version
and st.get("status") == "installing"
):
self.report(
"completed",
target,
"HomeCore Controller update completed successfully",
)
save_update_state(
{
"status": "completed",
"target_version": target,
"completed_at": int(time.time()),
}
)
def check_once(self):
desired = self.desired_release()
if not desired:
return
target = str(
desired.get("version", "")
).strip()
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()
if (
previous.get("target_version") == target
and previous.get("status") in (
"installing",
"failed",
)
):
attempted = int(
previous.get("attempted_at", 0)
)
# Avoid update loops.
if time.time() - attempted < 600:
return
self.report(
"checking",
target,
"Approved HomeCore release detected",
)
supervisor_request(
"POST",
"/store/reload",
)
time.sleep(3)
app = find_homecore_app()
slug = app.get("slug")
repository_version = str(
app.get("version")
or app.get("version_latest")
or ""
)
installed_version = str(
app.get("installed")
or self.current_version
)
if not slug:
raise RuntimeError(
"HomeCore App slug could not be determined"
)
if repository_version != target:
self.report(
"waiting_repository",
target,
"Home Assistant repository currently offers "
+ (repository_version or "no version"),
)
return
supervisor_request(
"GET",
f"/store/addons/{slug}/availability",
)
state = {
"status": "installing",
"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",
target,
"Backup requested; Supervisor update starting",
)
supervisor_request(
"POST",
f"/store/addons/{slug}/update",
{
"backup": True,
"background": True,
},
)
print(
"HOMECORE UPDATE: Supervisor accepted update job",
flush=True,
)
def run(self):
self.mark_completed_if_required()
while True:
try:
self.check_once()
except Exception as exc:
st = load_update_state()
target = st.get(
"target_version",
"",
)
save_update_state(
{
**st,
"status": "failed",
"target_version": target,
"detail": str(exc),
"attempted_at": int(time.time()),
}
)
self.report(
"failed",
target,
str(exc),
)
time.sleep(60)
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()

View File

@ -1,5 +1,5 @@
name: HomeCore Controller
version: "0.1.1"
version: "0.1.2"
slug: homecore_controller
description: HomeCore local controller agent for Home Assistant Green.
url: https://homecore.home-core.co.uk
@ -7,7 +7,7 @@ init: false
arch: [aarch64]
homeassistant_api: true
hassio_api: true
hassio_role: homeassistant
hassio_role: manager
ingress: true
ingress_port: 8099
panel_icon: mdi:home-automation