HomeCore Controller v0.1.1 commissioning flow
This commit is contained in:
parent
a8b7db3748
commit
53374d7e82
@ -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.
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
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.responses import HTMLResponse
|
||||
@ -7,76 +15,468 @@ 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('/')
|
||||
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
|
||||
|
||||
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='''<style>body{font-family:Inter,Arial,sans-serif;background:#0d1520;color:#edf4ff;margin:0}.wrap{max-width:720px;margin:60px auto;padding:28px}.card{background:#162231;border:1px solid #26384b;border-radius:18px;padding:28px;box-shadow:0 18px 50px #0005}h1{font-size:36px;margin:0 0 4px}.core{color:#78a9ff}p{color:#aebed0;line-height:1.5}label{font-size:13px;color:#aebed0}input{width:100%;box-sizing:border-box;padding:13px;margin:6px 0 16px;border-radius:10px;border:1px solid #36506a;background:#0e1926;color:white}button{background:#377dff;color:white;border:0;border-radius:10px;padding:13px 18px;font-weight:700;cursor:pointer}.ok{color:#55d68b}</style>'''
|
||||
app = FastAPI(
|
||||
title="HomeCore Controller",
|
||||
version=VERSION,
|
||||
)
|
||||
|
||||
@app.get('/',response_class=HTMLResponse)
|
||||
STYLE = '''
|
||||
<style>
|
||||
body{
|
||||
font-family:Inter,Arial,sans-serif;
|
||||
background:#0d1520;
|
||||
color:#edf4ff;
|
||||
margin:0
|
||||
}
|
||||
.wrap{
|
||||
max-width:720px;
|
||||
margin:60px auto;
|
||||
padding:28px
|
||||
}
|
||||
.card{
|
||||
background:#162231;
|
||||
border:1px solid #26384b;
|
||||
border-radius:18px;
|
||||
padding:28px;
|
||||
box-shadow:0 18px 50px #0005
|
||||
}
|
||||
h1{
|
||||
font-size:36px;
|
||||
margin:0 0 4px
|
||||
}
|
||||
.core{
|
||||
color:#78a9ff
|
||||
}
|
||||
p{
|
||||
color:#aebed0;
|
||||
line-height:1.5
|
||||
}
|
||||
label{
|
||||
display:block;
|
||||
font-size:13px;
|
||||
color:#aebed0;
|
||||
margin-bottom:6px
|
||||
}
|
||||
input,select{
|
||||
width:100%;
|
||||
box-sizing:border-box;
|
||||
padding:13px;
|
||||
margin:0 0 16px;
|
||||
border-radius:10px;
|
||||
border:1px solid #36506a;
|
||||
background:#0e1926;
|
||||
color:white
|
||||
}
|
||||
button{
|
||||
background:#377dff;
|
||||
color:white;
|
||||
border:0;
|
||||
border-radius:10px;
|
||||
padding:13px 18px;
|
||||
font-weight:700;
|
||||
cursor:pointer
|
||||
}
|
||||
.ok{
|
||||
color:#55d68b
|
||||
}
|
||||
.error{
|
||||
color:#ff7b7b
|
||||
}
|
||||
.small{
|
||||
color:#8195aa;
|
||||
font-size:13px
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function homecoreAction(form, endpoint) {
|
||||
const parts = window.location.pathname.split('/').filter(Boolean);
|
||||
|
||||
let base = '';
|
||||
|
||||
if (parts.length && parts[0].includes('homecore_controller')) {
|
||||
base = '/' + parts[0];
|
||||
}
|
||||
|
||||
form.action = base + '/' + endpoint;
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
'''
|
||||
|
||||
def page(body):
|
||||
return HTMLResponse(
|
||||
STYLE +
|
||||
'<div class="wrap"><div class="card">' +
|
||||
body +
|
||||
'</div></div>'
|
||||
)
|
||||
|
||||
def error_page(message):
|
||||
return page(
|
||||
'<h1>Home<span class="core">Core</span></h1>'
|
||||
'<h2 class="error">Unable to continue</h2>'
|
||||
f'<p>{html.escape(message)}</p>'
|
||||
'<p class="small">No controller binding has been changed.</p>'
|
||||
)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def setup_page():
|
||||
|
||||
s = load_state()
|
||||
if s.get('controller_id'):
|
||||
return STYLE+f'<div class="wrap"><div class="card"><h1>Home<span class="core">Core</span></h1><h2 class="ok">Controller connected</h2><p>Controller: {s["controller_id"]}</p><p>Property: {s.get("property_id")}</p><p>This Green is permanently bound to the HomeCore property.</p></div></div>'
|
||||
return STYLE+'''<div class="wrap"><div class="card"><h1>Home<span class="core">Core</span></h1><h2>Set up this controller</h2><p>Sign in with the customer's HomeCore Owner account. HomeCore will create or select the property and permanently bind this Home Assistant Green.</p><form method="post" action="./bind"><label>Owner email</label><input name="email" type="email" required><label>Password</label><input name="password" type="password" required><label>Property name</label><input name="property_name" required><button>Connect HomeCore</button></form></div></div>'''
|
||||
|
||||
@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('/')
|
||||
if s.get("controller_id"):
|
||||
return page(
|
||||
'<h1>Home<span class="core">Core</span></h1>'
|
||||
'<h2 class="ok">Controller connected</h2>'
|
||||
f'<p>Controller: {html.escape(s["controller_id"])}</p>'
|
||||
f'<p>Property: {html.escape(s.get("property_id",""))}</p>'
|
||||
f'<p>HomeCore Controller version: {VERSION}</p>'
|
||||
'<p>This Home Assistant Green is permanently bound to this HomeCore property.</p>'
|
||||
)
|
||||
|
||||
return page(
|
||||
'<h1>Home<span class="core">Core</span></h1>'
|
||||
'<h2>Set up this controller</h2>'
|
||||
'<p>Sign in with the customer\'s HomeCore Owner account. '
|
||||
'You will then choose one of the homes already created in HomeCore.</p>'
|
||||
|
||||
'<form method="post" '
|
||||
'onsubmit="return homecoreAction(this,\\'select-property\\')">'
|
||||
|
||||
'<label>Owner email</label>'
|
||||
'<input name="email" type="email" autocomplete="email" required>'
|
||||
|
||||
'<label>Password</label>'
|
||||
'<input name="password" type="password" autocomplete="current-password" required>'
|
||||
|
||||
'<button>Continue</button>'
|
||||
'</form>'
|
||||
|
||||
'<p class="small">'
|
||||
'Homes must be created in the HomeCore customer portal before a controller can be connected.'
|
||||
'</p>'
|
||||
)
|
||||
|
||||
@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});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()
|
||||
|
||||
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'<option value="{html.escape(p["id"])}">'
|
||||
f'{html.escape(p["name"])}</option>'
|
||||
for p in owned
|
||||
)
|
||||
|
||||
return page(
|
||||
'<h1>Home<span class="core">Core</span></h1>'
|
||||
'<h2>Select the customer home</h2>'
|
||||
'<p>Only homes owned by this HomeCore account can be connected.</p>'
|
||||
|
||||
'<form method="post" '
|
||||
'onsubmit="return homecoreAction(this,\\'bind\\')">'
|
||||
|
||||
f'<input type="hidden" name="nonce" value="{html.escape(nonce)}">'
|
||||
|
||||
'<label>Home</label>'
|
||||
f'<select name="property_id" required>{choices}</select>'
|
||||
|
||||
'<button>Connect HomeCore</button>'
|
||||
'</form>'
|
||||
|
||||
'<p class="small">'
|
||||
'After connection, this controller cannot be claimed by another HomeCore account.'
|
||||
'</p>'
|
||||
)
|
||||
|
||||
@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+'<div class="wrap"><div class="card"><h1>Home<span class="core">Core</span></h1><h2 class="ok">Connected</h2><p>This controller is now bound to the customer property. Sign into the HomeCore app with the same Owner account.</p></div></div>')
|
||||
|
||||
@app.get('/health')
|
||||
def health():return {'status':'ok','bound':bool(load_state().get('controller_id')),'fingerprint':fingerprint()}
|
||||
return page(
|
||||
'<h1>Home<span class="core">Core</span></h1>'
|
||||
'<h2 class="ok">Controller connected</h2>'
|
||||
'<p>This Home Assistant Green is now permanently bound to the selected HomeCore home.</p>'
|
||||
'<p>The customer can now sign into HomeCore using their normal HomeCore account.</p>'
|
||||
f'<p class="small">HomeCore Controller {VERSION}</p>'
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user