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
|
## 0.1.0
|
||||||
- Initial Owner/property binding.
|
|
||||||
- Persistent Ed25519 controller identity.
|
- Initial HomeCore Controller commissioning release.
|
||||||
- Signed controller session authentication.
|
|
||||||
- Home Assistant version heartbeat.
|
|
||||||
- Ingress commissioning screen.
|
|
||||||
|
|||||||
@ -1,82 +1,482 @@
|
|||||||
from pathlib import Path
|
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
|
import httpx
|
||||||
from fastapi import FastAPI,Form,HTTPException
|
from fastapi import FastAPI, Form, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
DATA=Path('/config');DATA.mkdir(parents=True,exist_ok=True)
|
VERSION = "0.1.1"
|
||||||
STATE=DATA/'state.json';KEY=DATA/'controller_ed25519.pem';OPTIONS=Path('/data/options.json')
|
|
||||||
|
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():
|
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:
|
try:
|
||||||
r=httpx.get('http://supervisor'+path,headers={'Authorization':'Bearer '+token},timeout=5);r.raise_for_status();return r.json()
|
return json.loads(OPTIONS.read_text())
|
||||||
except Exception:return None
|
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():
|
def controller_session():
|
||||||
s=load_state()
|
s = load_state()
|
||||||
if not s.get('controller_id'):return None
|
if not s.get("controller_id"):
|
||||||
api=options()['cloud_api'].rstrip('/')
|
return None
|
||||||
|
|
||||||
|
api = options()["cloud_api"].rstrip("/")
|
||||||
|
|
||||||
with httpx.Client(timeout=15) as c:
|
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']
|
ch = c.get(f"{api}/api/v1/controllers/{s['controller_id']}/challenge")
|
||||||
sig=base64.b64encode(KEYOBJ.sign(challenge.encode())).decode()
|
ch.raise_for_status()
|
||||||
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']
|
|
||||||
|
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():
|
def heartbeat_loop():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
s=load_state()
|
s = load_state()
|
||||||
if s.get('controller_id'):
|
|
||||||
token=controller_session(); api=options()['cloud_api'].rstrip('/')
|
if s.get("controller_id"):
|
||||||
info=supervisor_get('/core/info') or {}; ha_ver=((info.get('data') or {}).get('version') or '') if isinstance(info,dict) else ''
|
token = controller_session()
|
||||||
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)
|
api = options()["cloud_api"].rstrip("/")
|
||||||
except Exception:pass
|
|
||||||
|
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)
|
time.sleep(60)
|
||||||
|
|
||||||
app=FastAPI(title='HomeCore Controller',version='0.1.0')
|
app = FastAPI(
|
||||||
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>'''
|
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():
|
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)
|
s = load_state()
|
||||||
def bind(email:str=Form(...),password:str=Form(...),property_name:str=Form(...)):
|
|
||||||
if load_state().get('controller_id'):raise HTTPException(409,'Controller already bound')
|
if s.get("controller_id"):
|
||||||
api=options()['cloud_api'].rstrip('/')
|
return page(
|
||||||
with httpx.Client(timeout=20) as c:
|
'<h1>Home<span class="core">Core</span></h1>'
|
||||||
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}
|
'<h2 class="ok">Controller connected</h2>'
|
||||||
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)
|
f'<p>Controller: {html.escape(s["controller_id"])}</p>'
|
||||||
if not found:
|
f'<p>Property: {html.escape(s.get("property_id",""))}</p>'
|
||||||
r=c.post(api+'/api/v1/properties',headers=headers,json={'name':property_name,'timezone':'Europe/London'});r.raise_for_status();found=r.json()
|
f'<p>HomeCore Controller version: {VERSION}</p>'
|
||||||
info=supervisor_get('/core/info') or {};ha_ver=((info.get('data') or {}).get('version') or '') if isinstance(info,dict) else ''
|
'<p>This Home Assistant Green is permanently bound to this HomeCore property.</p>'
|
||||||
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()
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
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')
|
return page(
|
||||||
def health():return {'status':'ok','bound':bool(load_state().get('controller_id')),'fingerprint':fingerprint()}
|
'<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()
|
@app.get("/health")
|
||||||
if __name__=='__main__':uvicorn.run(app,host='0.0.0.0',port=8099)
|
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
|
name: HomeCore Controller
|
||||||
version: "0.1.0"
|
version: "0.1.1"
|
||||||
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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user