from pathlib import Path import os import json import base64 import hashlib import time import threading import secrets import html import httpx from fastapi import FastAPI, Form, HTTPException 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.10" print( "HOMECORE SUPERVISOR: token " + ("available" if os.getenv("SUPERVISOR_TOKEN") else "missing"), flush=True, ) DATA = Path("/config") DATA.mkdir(parents=True, exist_ok=True) STATE = DATA / "state.json" KEY = DATA / "controller_ed25519.pem" OPTIONS = Path("/data/options.json") PENDING = {} def options(): try: return json.loads(OPTIONS.read_text()) except Exception: return {"cloud_api": "https://api.homecore.home-core.co.uk"} def ensure_key(): if KEY.exists(): return serialization.load_pem_private_key(KEY.read_bytes(), password=None) k = Ed25519PrivateKey.generate() KEY.write_bytes( k.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), ) ) return k KEYOBJ = ensure_key() def pub_pem(): return KEYOBJ.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, ).decode() def fingerprint(): raw = KEYOBJ.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw, ) return hashlib.sha256(raw).hexdigest() def load_state(): try: return json.loads(STATE.read_text()) except Exception: return {} def save_state(s): STATE.write_text(json.dumps(s, indent=2)) def supervisor_get(path): token = os.getenv("SUPERVISOR_TOKEN") if not token: return None try: r = httpx.get( "http://supervisor" + path, headers={"Authorization": "Bearer " + token}, timeout=5, ) r.raise_for_status() return r.json() except Exception: return None def ha_version(): info = supervisor_get("/core/info") or {} if isinstance(info, dict): return ((info.get("data") or {}).get("version") or "") return "" def cloud_detail(response): try: data = response.json() if isinstance(data, dict): return data.get("detail") or str(data) except Exception: pass return f"HomeCore Cloud returned HTTP {response.status_code}" def controller_session(): s = load_state() if not s.get("controller_id"): return None api = options()["cloud_api"].rstrip("/") with httpx.Client(timeout=15) as c: ch = c.get(f"{api}/api/v1/controllers/{s['controller_id']}/challenge") ch.raise_for_status() challenge = ch.json()["challenge"] sig = base64.b64encode( KEYOBJ.sign(challenge.encode()) ).decode() r = c.post( f"{api}/api/v1/controllers/{s['controller_id']}/session", json={ "challenge": challenge, "signature_b64": sig, }, ) r.raise_for_status() return r.json()["controller_token"] def heartbeat_loop(): while True: try: s = load_state() if s.get("controller_id"): token = controller_session() api = options()["cloud_api"].rstrip("/") httpx.post( f"{api}/api/v1/controllers/{s['controller_id']}/heartbeat", headers={"Authorization": "Bearer " + token}, json={ "software_version": VERSION, "ha_version": ha_version(), }, timeout=10, ) except Exception: pass time.sleep(60) app = FastAPI( title="HomeCore Controller", version=VERSION, ) STYLE = ''' ''' def page(body): return HTMLResponse( STYLE + '
{html.escape(message)}
' 'No controller binding has been changed.
' ) @app.get("/", response_class=HTMLResponse) def setup_page(): s = load_state() if s.get("controller_id"): return page( 'Controller: {html.escape(s["controller_id"])}
' f'Property: {html.escape(s.get("property_id",""))}
' f'HomeCore Controller version: {VERSION}
' 'This Home Assistant Green is permanently bound to this HomeCore property.
' ) return page( 'Sign in with the customer\'s HomeCore Owner account. ' 'You will then choose one of the homes already created in HomeCore.
' '' '' 'Homes must be created in the HomeCore customer portal before a controller can be connected.' '
' ) @app.post("/", response_class=HTMLResponse) def ingress_form_submit( stage: str = Form(...), email: str = Form(""), password: str = Form(""), nonce: str = Form(""), property_id: str = Form(""), ): """ All commissioning forms submit to the ingress root. This deliberately avoids nested browser paths because Home Assistant Ingress owns the external URL prefix. """ if stage == "select-property": return select_property( email=email, password=password, ) if stage == "bind": return bind( nonce=nonce, property_id=property_id, ) return error_page( "Unknown HomeCore setup step." ) @app.post("/select-property", response_class=HTMLResponse) def select_property( email: str = Form(...), password: str = Form(...), ): api = options()["cloud_api"].rstrip("/") try: with httpx.Client(timeout=20) as c: r = c.post( api + "/api/v1/auth/login", json={ "email": email, "password": password, }, ) if r.status_code >= 400: return error_page("Incorrect HomeCore email address or password.") token = r.json()["access_token"] headers = { "Authorization": "Bearer " + token } r = c.get( api + "/api/v1/properties", headers=headers, ) if r.status_code >= 400: return error_page(cloud_detail(r)) owned = [ p for p in r.json() if p.get("owner") is True ] except Exception as e: return error_page( "The controller could not contact HomeCore Cloud. " + str(e) ) if not owned: return error_page( "This Owner account does not have a HomeCore home yet. " "Create the home at homecore.home-core.co.uk, then return here." ) nonce = secrets.token_urlsafe(32) PENDING[nonce] = { "token": token, "expires": time.time() + 300, "property_ids": [p["id"] for p in owned], } choices = "".join( f'' for p in owned ) return page( 'Only homes owned by this HomeCore account can be connected.
' '' '' 'After connection, this controller cannot be claimed by another HomeCore account.' '
' ) @app.post("/bind", response_class=HTMLResponse) def bind( nonce: str = Form(...), property_id: str = Form(...), ): if load_state().get("controller_id"): raise HTTPException( 409, "Controller already bound", ) pending = PENDING.get(nonce) if not pending: return error_page( "The setup session has expired. Sign in again." ) if pending["expires"] < time.time(): PENDING.pop(nonce, None) return error_page( "The setup session has expired. Sign in again." ) if property_id not in pending["property_ids"]: return error_page( "This HomeCore account does not own the selected property." ) token = pending["token"] api = options()["cloud_api"].rstrip("/") headers = { "Authorization": "Bearer " + token } payload = { "property_id": property_id, "public_key": pub_pem(), "fingerprint": fingerprint(), "software_version": VERSION, "ha_version": ha_version(), } try: with httpx.Client(timeout=20) as c: r = c.post( api + "/api/v1/controllers/bind", headers=headers, json=payload, ) if r.status_code >= 400: return error_page(cloud_detail(r)) out = r.json() except Exception as e: return error_page( "The controller could not contact HomeCore Cloud. " + str(e) ) PENDING.pop(nonce, None) save_state(out) return page( 'This Home Assistant Green is now permanently bound to the selected HomeCore home.
' 'The customer can now sign into HomeCore using their normal HomeCore account.
' f'HomeCore Controller {VERSION}
' ) @app.get("/health") def health(): return { "status": "ok", "bound": bool(load_state().get("controller_id")), "fingerprint": fingerprint(), "version": VERSION, } threading.Thread( target=heartbeat_loop, 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, host="0.0.0.0", port=8099, )