From 53374d7e82099f53a61cf52044a5fde2582b0d0a Mon Sep 17 00:00:00 2001 From: HomeCore Date: Wed, 16 Sep 2026 10:03:34 +0000 Subject: [PATCH] HomeCore Controller v0.1.1 commissioning flow --- homecore_controller/CHANGELOG.md | 18 +- homecore_controller/app/main.py | 514 +++++++++++++++++++++++++++---- homecore_controller/config.yaml | 2 +- 3 files changed, 470 insertions(+), 64 deletions(-) diff --git a/homecore_controller/CHANGELOG.md b/homecore_controller/CHANGELOG.md index cac1a36..f5cbcc7 100644 --- a/homecore_controller/CHANGELOG.md +++ b/homecore_controller/CHANGELOG.md @@ -1,8 +1,14 @@ -# Changelog +# HomeCore Controller Changelog + +## 0.1.1 + +- Controller setup can no longer create properties. +- Owner signs in first. +- HomeCore displays only homes already owned by that account. +- Installer selects the correct home from a list. +- Controller binding remains permanent. +- Improved commissioning errors and version reporting. ## 0.1.0 -- Initial Owner/property binding. -- Persistent Ed25519 controller identity. -- Signed controller session authentication. -- Home Assistant version heartbeat. -- Ingress commissioning screen. + +- Initial HomeCore Controller commissioning release. diff --git a/homecore_controller/app/main.py b/homecore_controller/app/main.py index 9dae2ae..f891160 100644 --- a/homecore_controller/app/main.py +++ b/homecore_controller/app/main.py @@ -1,82 +1,482 @@ from pathlib import Path -import os,json,base64,hashlib,time,threading +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 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 -DATA=Path('/config');DATA.mkdir(parents=True,exist_ok=True) -STATE=DATA/'state.json';KEY=DATA/'controller_ed25519.pem';OPTIONS=Path('/data/options.json') +VERSION = "0.1.1" + +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.example.com'} -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():return hashlib.sha256(KEYOBJ.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.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 + 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('/') + 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'] + 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('/') - info=supervisor_get('/core/info') or {}; ha_ver=((info.get('data') or {}).get('version') or '') if isinstance(info,dict) else '' - httpx.post(f"{api}/api/v1/controllers/{s['controller_id']}/heartbeat",headers={'Authorization':'Bearer '+token},json={'software_version':'0.1.0','ha_version':ha_ver},timeout=10) - except Exception:pass + 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='0.1.0') -STYLE='''''' +app = FastAPI( + title="HomeCore Controller", + version=VERSION, +) -@app.get('/',response_class=HTMLResponse) +STYLE = ''' + + + +''' + +def page(body): + return HTMLResponse( + STYLE + + '
' + + body + + '
' + ) + +def error_page(message): + return page( + '

HomeCore

' + '

Unable to continue

' + f'

{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 STYLE+f'

HomeCore

Controller connected

Controller: {s["controller_id"]}

Property: {s.get("property_id")}

This Green is permanently bound to the HomeCore property.

' - return STYLE+'''

HomeCore

Set up this controller

Sign in with the customer's HomeCore Owner account. HomeCore will create or select the property and permanently bind this Home Assistant Green.

''' -@app.post('/bind',response_class=HTMLResponse) -def bind(email:str=Form(...),password:str=Form(...),property_name:str=Form(...)): - if load_state().get('controller_id'):raise HTTPException(409,'Controller already bound') - api=options()['cloud_api'].rstrip('/') - with httpx.Client(timeout=20) as c: - r=c.post(api+'/api/v1/auth/login',json={'email':email,'password':password});r.raise_for_status();token=r.json()['access_token'];headers={'Authorization':'Bearer '+token} - r=c.get(api+'/api/v1/properties',headers=headers);r.raise_for_status();found=next((p for p in r.json() if p['name'].lower()==property_name.lower() and p.get('owner')),None) - if not found: - r=c.post(api+'/api/v1/properties',headers=headers,json={'name':property_name,'timezone':'Europe/London'});r.raise_for_status();found=r.json() - info=supervisor_get('/core/info') or {};ha_ver=((info.get('data') or {}).get('version') or '') if isinstance(info,dict) else '' - payload={'property_id':found['id'],'public_key':pub_pem(),'fingerprint':fingerprint(),'software_version':'0.1.0','ha_version':ha_ver} - r=c.post(api+'/api/v1/controllers/bind',headers=headers,json=payload);r.raise_for_status();out=r.json() + s = load_state() + + if s.get("controller_id"): + return page( + '

HomeCore

' + '

Controller connected

' + f'

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( + '

HomeCore

' + '

Set up this controller

' + '

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("/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( + '

HomeCore

' + '

Select the customer home

' + '

Only homes owned by this HomeCore account can be connected.

' + + '
' + + f'' + + '' + f'' + + '' + '
' + + '

' + '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 HTMLResponse(STYLE+'

HomeCore

Connected

This controller is now bound to the customer property. Sign into the HomeCore app with the same Owner account.

') -@app.get('/health') -def health():return {'status':'ok','bound':bool(load_state().get('controller_id')),'fingerprint':fingerprint()} + return page( + '

HomeCore

' + '

Controller connected

' + '

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}

' + ) -threading.Thread(target=heartbeat_loop,daemon=True).start() -if __name__=='__main__':uvicorn.run(app,host='0.0.0.0',port=8099) +@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() + +if __name__ == "__main__": + uvicorn.run( + app, + host="0.0.0.0", + port=8099, + ) diff --git a/homecore_controller/config.yaml b/homecore_controller/config.yaml index 38b1b9b..a8f56ad 100644 --- a/homecore_controller/config.yaml +++ b/homecore_controller/config.yaml @@ -1,5 +1,5 @@ name: HomeCore Controller -version: "0.1.0" +version: "0.1.1" slug: homecore_controller description: HomeCore local controller agent for Home Assistant Green. url: https://homecore.home-core.co.uk