homecore-apps/homecore_controller/app/main.py

2113 lines
108 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.middleware.cors import CORSMiddleware
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 = "1.0.3"
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)
# ===== HomeCore 1.0 product layer =====
from pathlib import Path
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import json
import os
import time
import secrets
import threading
import uuid
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, Response
CORE_API = "http://supervisor/core/api"
DEVICE_DOMAINS = {
"light", "climate", "water_heater", "camera", "cover",
"alarm_control_panel", "media_player", "switch", "lock",
"event", "binary_sensor", "fan", "scene", "script",
"automation", "button", "remote", "vacuum", "sensor", "number", "select",
}
CATEGORY_LABELS = {
"lighting": "Lighting",
"heating": "Heating",
"cctv": "CCTV",
"blinds": "Blinds",
"alarm": "Alarm",
"doorbell": "Intercom",
"entertainment": "Entertainment",
"security": "Security",
"switch": "Switches",
"fan": "Fans",
"scene": "Scenes",
"automation": "Automations",
"sensor": "Sensors",
"other": "Other",
}
HOMECORE_DEVICE_CATALOG = [
{
"id": "shelly",
"name": "Shelly",
"category": "Smart devices",
"description": "Relays, switches, dimmers, plugs, shutters, meters and supported Shelly devices.",
"capabilities": ["Lighting", "Switching", "Blinds", "Energy"],
"variants": [
{
"id": "shelly",
"name": "Shelly Wi-Fi / Ethernet",
"handlers": ["shelly"],
"note": "HomeCore discovers the Shelly on your network or lets you enter its address.",
},
{
"id": "shelly_bluetooth",
"name": "Shelly BLU / Bluetooth",
"handlers": ["bthome"],
"note": "For compatible Shelly BLU devices exposed through BTHome.",
},
{
"id": "shelly_zwave",
"name": "Shelly Wave / Z-Wave",
"handlers": ["zwave_js"],
"note": "Requires a Z-Wave radio/controller connected to the HomeCore Green.",
},
],
},
{
"id": "philips_hue",
"name": "Philips Hue",
"category": "Lighting",
"description": "Hue lights, lamps, light strips, switches, remotes and motion sensors.",
"capabilities": ["Lighting", "Scenes", "Sensors"],
"variants": [
{
"id": "hue_bridge",
"name": "Hue Bridge",
"handlers": ["hue"],
"note": "Recommended. HomeCore discovers the Hue Bridge and guides you through pairing.",
},
{
"id": "hue_bluetooth",
"name": "Hue Bluetooth",
"handlers": ["hue_ble"],
"note": "For supported Hue Bluetooth lights when a compatible Bluetooth adapter is available.",
},
],
},
{
"id": "hikvision_axpro",
"name": "Hikvision AX PRO",
"category": "Alarm & security",
"description": "AX PRO / AX Hub alarm panel, zones and supported wireless detectors.",
"capabilities": ["Alarm", "Zones", "Sensors"],
"variants": [
{
"id": "hikvision_axpro",
"name": "Hikvision AX PRO",
"handlers": ["hikvision_axpro"],
"note": "Uses the HomeCore-compatible AX PRO local driver when installed.",
"unavailable": "The AX PRO driver is not installed on this Green yet.",
}
],
},
{
"id": "yale",
"name": "Yale",
"category": "Alarm & security",
"description": "Yale Smart Living/Sync alarms plus supported Yale Home devices.",
"capabilities": ["Alarm", "Locks", "Sensors", "Doorbell"],
"variants": [
{
"id": "yale_smart_living",
"name": "Yale Smart Living / Sync Alarm",
"handlers": ["yale_smart_alarm"],
"note": "For the established Yale Smart Living / Sync alarm platform.",
},
{
"id": "yale_home",
"name": "Yale Home",
"handlers": ["yale"],
"note": "For Yale Home locks, doorbells and supported Yale Home devices.",
},
{
"id": "yale_new_alarm",
"name": "New Yale Smart Alarm",
"handlers": ["yale_smart_alarm_ce"],
"note": "Uses the HomeCore-compatible driver for the newer Yale Home alarm range when installed.",
"unavailable": "The new Yale Smart Alarm driver is not installed on this Green yet.",
},
],
},
{
"id": "heatmiser",
"name": "Heatmiser",
"category": "Heating",
"description": "Heatmiser thermostats, heating zones and supported NeoHub systems.",
"capabilities": ["Heating", "Thermostats", "Zones"],
"variants": [
{
"id": "heatmiser_neo",
"name": "Heatmiser Neo / NeoHub",
"handlers": ["heatmiserneo"],
"note": "Uses the HomeCore-compatible NeoHub driver when installed.",
"unavailable": "The Heatmiser Neo driver is not installed on this Green yet.",
},
{
"id": "heatmiser_legacy",
"name": "Heatmiser legacy gateway",
"handlers": ["heatmiser"],
"note": "For legacy Heatmiser gateways when a setup handler is available.",
"unavailable": "This Heatmiser system requires the HomeCore Heatmiser driver.",
},
],
},
{
"id": "sky_glass",
"name": "Sky Glass",
"category": "Entertainment",
"description": "Sky Glass television and compatible Sky entertainment controls.",
"capabilities": ["TV", "Media"],
"variants": [
{
"id": "sky_glass",
"name": "Sky Glass",
"handlers": ["sky_glass", "sky_stream"],
"note": "HomeCore will use a compatible Sky Glass/Stream driver when one is installed.",
"unavailable": "A compatible Sky Glass driver is not installed on this Green yet.",
}
],
},
{
"id": "hive",
"name": "Hive",
"category": "Heating & smart devices",
"description": "Hive heating, hot water, radiator valves, plugs, lights and supported sensors.",
"capabilities": ["Heating", "Hot water", "Lighting", "Plugs", "Sensors"],
"variants": [
{
"id": "hive",
"name": "Hive",
"handlers": ["hive"],
"note": "Sign in with the Hive account owner. Hive requires two-factor authentication.",
}
],
},
]
SESSIONS = {}
LOCK = threading.RLock()
APP_HTML = '<!doctype html>\n<html>\n<head>\n<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">\n<meta name="theme-color" content="#07111b">\n<title>HomeCore</title>\n<style>\n:root{--bg:#07111b;--panel:#0e1b29;--panel2:#132334;--line:#233a50;--text:#f2f7ff;--muted:#8eabc4;--blue:#5b96ff;--green:#55d68b;--amber:#f5b94c;--red:#ff6d77;--shadow:0 20px 60px #0005}\nhtml[data-theme="light"]{--bg:#f3f7fb;--panel:#fff;--panel2:#eef4fa;--line:#d8e3ed;--text:#102030;--muted:#62788c;--blue:#276fe8;--shadow:0 20px 60px #27425c1c}\n*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif;min-height:100vh}button,input,select{font:inherit}\n.shell{max-width:1500px;margin:auto;padding:26px 30px 118px}.top{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:28px}.brand{display:flex;align-items:center;gap:18px}.logo{font-weight:900;font-size:27px;letter-spacing:-1.4px}.logo span{color:var(--blue)}.home-title{font-size:13px;color:var(--muted);margin-top:3px}.top-actions{display:flex;gap:10px;align-items:center}\n.pill,.icon-btn,.btn{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:13px;padding:10px 13px;cursor:pointer}.btn.primary{background:var(--blue);border-color:var(--blue);color:white;font-weight:800}.btn.danger{background:#ff6d771c;border-color:#ff6d7755;color:var(--red)}.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block;background:var(--amber);margin-right:7px}.status-dot.ok{background:var(--green)}.status-dot.off{background:var(--red)}\n.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:18px;margin:10px 0 22px}.eyebrow{color:var(--blue);font-size:12px;text-transform:uppercase;letter-spacing:.14em;font-weight:800}h1{margin:3px 0 0;font-size:36px;letter-spacing:-1.5px}h2{margin:0 0 13px;font-size:20px}h3{margin:0 0 8px}.muted{color:var(--muted)}\n.grid{display:grid;gap:14px}.summary{grid-template-columns:repeat(6,minmax(0,1fr))}.stat{background:var(--panel);border:1px solid var(--line);border-radius:22px;padding:18px;min-height:115px}.stat .k{color:var(--muted);font-size:12px}.stat .v{font-size:22px;font-weight:850;margin-top:14px}.stat .sub{font-size:11px;color:var(--muted);margin-top:6px}.section{margin-top:26px}.cards{grid-template-columns:repeat(auto-fill,minmax(245px,1fr))}\n.card{background:var(--panel);border:1px solid var(--line);border-radius:22px;padding:17px;box-shadow:var(--shadow);overflow:hidden}.card.active{border-color:#5b96ffaa}.card-top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.device-name{font-weight:800}.device-sub{font-size:12px;color:var(--muted);margin-top:3px}.state{font-size:12px;border:1px solid var(--line);border-radius:999px;padding:5px 8px;color:var(--muted)}.state.on{color:var(--green);border-color:#55d68b66}.controls{display:flex;flex-wrap:wrap;gap:8px;margin-top:15px}.controls button{border:1px solid var(--line);background:var(--panel2);color:var(--text);border-radius:10px;padding:8px 10px;cursor:pointer;font-size:12px;font-weight:700}.range{width:100%;accent-color:var(--blue)}.temp{font-size:32px;font-weight:850;margin-top:12px}.camera{aspect-ratio:16/9;width:100%;object-fit:cover;border-radius:14px;background:#02070b;margin-top:12px}\n.setup{background:linear-gradient(135deg,#5b96ff1c,#55d68b12);border:1px solid #5b96ff55;border-radius:22px;padding:22px;display:flex;justify-content:space-between;gap:18px;align-items:center}.setup h2{margin:0 0 6px}.room-empty,.empty{padding:34px 20px;text-align:center;border:1px dashed var(--line);border-radius:22px;color:var(--muted)}\n.bottom-nav{position:fixed;left:50%;transform:translateX(-50%);bottom:18px;width:min(760px,calc(100% - 28px));display:grid;gap:9px;z-index:20}.bottom-nav.two{grid-template-columns:1.15fr 1fr}.bottom-nav.three{grid-template-columns:1fr 1.15fr 1fr}.nav-card{background:var(--panel);border:1px solid var(--line);color:var(--muted);border-radius:17px;padding:12px 14px;text-align:left;cursor:pointer;min-width:0;box-shadow:var(--shadow)}.nav-card.current{background:var(--blue);color:white;border-color:var(--blue)}.nav-card small{display:block;opacity:.75;font-size:10px;text-transform:uppercase;letter-spacing:.11em}.nav-card strong{display:block;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.overlay{position:fixed;inset:0;background:#02080eb8;backdrop-filter:blur(8px);z-index:50;display:none;align-items:stretch;justify-content:flex-end}.overlay.open{display:flex}.drawer{width:min(820px,100%);height:100%;overflow:auto;background:var(--bg);border-left:1px solid var(--line);padding:28px}.drawer-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}.settings-nav{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:18px}.settings-nav button{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:999px;padding:8px 11px;cursor:pointer}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.settings-card{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:18px}.field{margin:10px 0}.field label{display:block;color:var(--muted);font-size:12px;margin-bottom:6px}.field input,.field select,.search{width:100%;background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:10px;padding:10px}\n.room-row,.device-row,.person-row,.activity-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}.row-actions{display:flex;gap:7px;align-items:center;flex-wrap:wrap}.mini{padding:7px 9px!important;font-size:11px!important}.tag{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:3px 7px;font-size:10px;color:var(--muted);margin:3px 4px 0 0}\n.discovery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin-top:12px}.discover-card{background:var(--panel2);border:1px solid var(--line);border-radius:15px;padding:14px}.discover-name{font-weight:800}.discover-meta{font-size:11px;color:var(--muted);margin:5px 0 12px;word-break:break-all}\n.modal-wrap{position:fixed;inset:0;background:#02080ecc;z-index:80;display:none;place-items:center;padding:18px}.modal-wrap.open{display:grid}.modal{width:min(540px,100%);background:var(--panel);border:1px solid var(--line);border-radius:24px;padding:22px;box-shadow:var(--shadow)}.toast{position:fixed;right:20px;bottom:105px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:12px 14px;z-index:100;display:none;max-width:420px}.toast.show{display:block}\n.ring{position:fixed;inset:0;background:#000d;z-index:120;display:none;place-items:center;padding:20px}.ring.show{display:grid}.ring-card{width:min(650px,100%);background:var(--panel);border:1px solid var(--line);border-radius:28px;padding:24px;text-align:center}.ring-icon{font-size:56px}.ring-title{font-size:30px;font-weight:900;margin:10px 0}.ring-actions{display:flex;gap:10px;justify-content:center;margin-top:18px}.login{min-height:78vh;display:grid;place-items:center}.login-card{width:min(430px,100%);background:var(--panel);border:1px solid var(--line);border-radius:28px;padding:28px}.login-card input{width:100%;margin:7px 0 12px;padding:12px;background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:11px}\n@media(max-width:1000px){.summary{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.shell{padding:18px 16px 86px}.summary{grid-template-columns:repeat(2,1fr)}.settings-grid{grid-template-columns:1fr}h1{font-size:30px}.cards{grid-template-columns:1fr}.drawer{padding:20px}.setup{display:block}.setup .controls{margin-top:16px}.device-row{grid-template-columns:1fr}.row-actions{justify-content:flex-start}}\n</style>\n</head>\n<body>\n<div class="shell"><div class="top"><div class="brand"><div class="logo">Home<span>Core</span></div><div id="topHome" class="home-title">Loading home…</div></div><div class="top-actions"><button class="pill" id="cloudStatus"><span id="cloudDot" class="status-dot"></span><span id="cloudText">Syncing</span></button><button class="icon-btn" onclick="openSettings()">⚙</button></div></div><main id="content"></main></div>\n<div id="bottomNav" class="bottom-nav"></div>\n<div id="settingsOverlay" class="overlay"><div class="drawer"><div class="drawer-head"><div><div class="eyebrow">HomeCore</div><h1>Settings</h1></div><button class="icon-btn" onclick="closeSettings()">✕</button></div><div id="settingsContent"></div></div></div>\n<div id="modalWrap" class="modal-wrap"><div id="modal" class="modal"></div></div><div id="toast" class="toast"></div>\n<div id="ring" class="ring"><div class="ring-card"><div class="ring-icon">◉</div><div class="eyebrow">HomeCore Intercom</div><div id="ringTitle" class="ring-title">Someone is at the door</div><div id="ringRoom" class="muted"></div><div class="ring-actions"><button class="btn" onclick="dismissRing()">Dismiss</button><button class="btn primary" onclick="viewDoorbell()">View</button></div></div></div>\n<script>\nconst HC={home:null,devices:[],rooms:[],screens:[],current:0,poll:null,discovered:[],lastDoorbell:{}};\nconst BASE=location.pathname.endsWith(\'/\')?location.pathname:location.pathname+\'/\';\nconst q=s=>document.querySelector(s);\nconst esc=s=>String(s??\'\').replace(/[&<>"\']/g,m=>({\'&\':\'&amp;\',\'<\':\'&lt;\',\'>\':\'&gt;\',\'"\':\'&quot;\',"\'":\'&#39;\'}[m]));\nasync function api(path,opt={}){const u=BASE+path.replace(/^\\/+/,\'\');opt.headers={...(opt.headers||{})};if(opt.body&&typeof opt.body!==\'string\'){opt.headers[\'Content-Type\']=\'application/json\';opt.body=JSON.stringify(opt.body)}const r=await fetch(u,opt);if(r.status===401){if(!location.pathname.includes(\'hassio_ingress\'))showLogin();throw new Error(\'HomeCore sign in required\')}if(!r.ok){let t=await r.text();try{const j=JSON.parse(t);t=j.detail||t}catch{}throw new Error(t)}const ct=r.headers.get(\'content-type\')||\'\';return ct.includes(\'application/json\')?r.json():r.text()}\nfunction toast(msg,good=true){const e=q(\'#toast\');e.textContent=msg;e.style.borderColor=good?\'#55d68b88\':\'#ff6d7788\';e.classList.add(\'show\');setTimeout(()=>e.classList.remove(\'show\'),3200)}\nfunction setCloud(state){const dot=q(\'#cloudDot\'),txt=q(\'#cloudText\');dot.className=\'status-dot \'+(state===\'connected\'?\'ok\':state===\'offline\'?\'off\':\'\');txt.textContent=state===\'connected\'?\'Cloud connected\':state===\'offline\'?\'Offline\':\'Syncing\'}\nfunction roomName(id){const r=HC.rooms.find(x=>x.id===id);return r?r.name:\'Unassigned\'}\nfunction stateLabel(d){if(!d.available)return\'Offline\';if(d.category===\'lighting\')return d.state===\'on\'?\'On\':\'Off\';if(d.category===\'heating\')return d.attributes.hvac_action||d.state;if(d.category===\'blinds\'&&d.attributes.current_position!=null)return d.attributes.current_position+\'%\';if(d.category===\'entertainment\')return d.state===\'playing\'?\'Playing\':d.state;return String(d.state||\'\')}\nfunction renderNav(){const e=q(\'#bottomNav\'),n=HC.screens.length,i=HC.current;if(n<=1){e.innerHTML=\'\';e.className=\'bottom-nav\';return}if(n===2){const other=i===0?1:0;e.className=\'bottom-nav two\';e.innerHTML=`<button class="nav-card current" onclick="go(${i})"><small>Current</small><strong>${esc(title(HC.screens[i]))}</strong></button><button class="nav-card" onclick="go(${other})"><small>Next</small><strong>${esc(title(HC.screens[other]))}</strong></button>`;return}const idx=[(i-1+n)%n,i,(i+1)%n];e.className=\'bottom-nav three\';e.innerHTML=idx.map((v,k)=>`<button class="nav-card ${k===1?\'current\':\'\'}" onclick="go(${v})"><small>${k===0?\'Previous\':k===1?\'Current\':\'Next\'}</small><strong>${esc(title(HC.screens[v]))}</strong></button>`).join(\'\')}\nfunction title(s){return s.type===\'overview\'?\'Overview\':s.room.name}function go(i){HC.current=i;renderScreen();renderNav();window.scrollTo({top:0,behavior:\'smooth\'})}\nfunction stat(k,v,sub=\'\'){return`<div class="stat"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div><div class="sub">${esc(sub)}</div></div>`}\nfunction button(label,id,cmd,extra={}){return`<button onclick=\'cmd(${JSON.stringify(id)},${JSON.stringify(cmd)},${JSON.stringify(extra)})\'>${esc(label)}</button>`}\nfunction deviceCard(d){let c=\'\';if(d.category===\'lighting\'){c+=d.state===\'on\'?button(\'Off\',d.id,\'turn_off\'):button(\'On\',d.id,\'turn_on\');if(d.capabilities.includes(\'brightness\'))c+=`<input class="range" type="range" min="1" max="100" value="${Math.round((d.attributes.brightness||255)/2.55)}" onchange="cmd(\'${esc(d.id)}\',\'turn_on\',{brightness_pct:Number(this.value)})">`}else if(d.category===\'heating\'){const t=d.attributes.current_temperature,target=d.attributes.temperature;c+=`<div class="temp">${t!=null?esc(t)+\'°\':\'—\'}</div><div class="muted" style="font-size:12px">Target ${target!=null?esc(target)+\'°\':\'—\'}</div>`;if(target!=null)c+=button(\'−\',d.id,\'set_temperature\',{temperature:Number(target)-0.5})+button(\'+\',d.id,\'set_temperature\',{temperature:Number(target)+0.5})}else if(d.category===\'blinds\'){c+=button(\'Open\',d.id,\'open\')+button(\'Stop\',d.id,\'stop\')+button(\'Close\',d.id,\'close\')}else if(d.category===\'alarm\'){c+=button(\'Home\',d.id,\'arm_home\')+button(\'Away\',d.id,\'arm_away\')+button(\'Night\',d.id,\'arm_night\')+button(\'Disarm\',d.id,\'disarm\')}else if(d.category===\'entertainment\'){c+=button(\'Power\',d.id,d.state===\'off\'?\'turn_on\':\'turn_off\')+button(\'Play / pause\',d.id,\'play_pause\')}else if(d.category===\'security\'){c+=button(d.state===\'locked\'?\'Unlock\':\'Lock\',d.id,d.state===\'locked\'?\'unlock\':\'lock\')}else if(d.category===\'switch\'||d.category===\'fan\'){c+=button(d.state===\'on\'?\'Off\':\'On\',d.id,d.state===\'on\'?\'turn_off\':\'turn_on\')}else if(d.category===\'scene\'){c+=button(\'Activate\',d.id,\'activate\')}else if(d.category===\'automation\'){c+=button(\'Run\',d.id,\'trigger\')}else if(d.category===\'cctv\'){c+=`<img class="camera" loading="lazy" src="${BASE}${d.camera_url.replace(/^\\//,\'\')}?t=${Date.now()}">`}return`<div class="card ${[\'on\',\'playing\',\'armed_home\',\'armed_away\',\'armed_night\'].includes(d.state)?\'active\':\'\'}"><div class="card-top"><div><div class="device-name">${esc(d.name)}</div><div class="device-sub">${esc(roomName(d.room_id))} · ${esc(d.category_label)}</div></div><div class="state ${d.state===\'on\'?\'on\':\'\'}">${esc(stateLabel(d))}</div></div><div class="controls">${c}</div></div>`}\nfunction section(name,items){return items.length?`<section class="section"><h2>${esc(name)}</h2><div class="grid cards">${items.map(deviceCard).join(\'\')}</div></section>`:\'\'}\nfunction renderOverview(){const d=HC.devices,o=HC.home.overview;const count=c=>d.filter(x=>x.category===c).length;const alarmCount=count(\'alarm\'),lockCount=count(\'security\'),mediaCount=count(\'entertainment\'),cameraCount=count(\'cctv\'),lightCount=count(\'lighting\'),heatCount=count(\'heating\');const setup=(!HC.rooms.length||!d.length)?`<div class="setup"><div><h2>Finish setting up ${esc(HC.home.home_name)}</h2><div class="muted">${!HC.rooms.length?\'Create your rooms, then scan Home Assistant for devices and add them to HomeCore.\':\'Scan Home Assistant and add the devices you want to control in HomeCore.\'}</div></div><div class="controls"><button class="btn primary" onclick="openSettings(\'devices\')">Add devices</button>${!HC.rooms.length?\'<button class="btn" onclick="openSettings(\\\'rooms\\\')">Create room</button>\':\'\'}</div></div>`:\'\';const recent=(HC.home.activity||[]).slice(0,6);return`<div class="page-head"><div><div class="eyebrow">HomeCore</div><h1>Overview</h1></div></div>${setup}<div class="grid summary" style="margin-top:${setup?\'18px\':\'0\'}">${stat(\'Lighting\',lightCount?`${o.lights_on} / ${o.lights_total}`:\'Not set up\',lightCount?\'on\':\'Add lighting\')}${stat(\'Temperature\',heatCount?(o.average_temperature==null?\'No reading\':o.average_temperature+\'°C\'):\'Not set up\',heatCount?\'average\':\'Add heating\')}${stat(\'Media\',mediaCount?o.media_active:\'Not set up\',mediaCount?\'active\':\'Add entertainment\')}${stat(\'Cameras\',cameraCount?o.cameras:\'Not set up\',cameraCount?\'available\':\'Add cameras\')}${stat(\'Alarm\',alarmCount?(o.alarm_state||\'Unknown\'):\'Not set up\',alarmCount?\'\':\'Add alarm\')}${stat(\'Locks\',lockCount?o.unlocked:\'Not set up\',lockCount?\'unlocked\':\'Add locks\')}</div>${section(\'Favorites\',d.filter(x=>x.favorite))}${section(\'Scenes\',d.filter(x=>x.category===\'scene\'))}<section class="section"><h2>Recent activity</h2>${recent.length?`<div class="settings-card">${recent.map(a=>`<div class="activity-row"><div><strong>${esc(a.type.replaceAll(\'.\',\' \'))}</strong><div class="muted" style="font-size:11px">${esc(new Date(a.created_at).toLocaleString())}</div></div><div class="muted">${esc(a.detail?.name||a.detail?.entity_id||\'\')}</div></div>`).join(\'\')}</div>`:`<div class="empty">No HomeCore activity yet. Setup changes and device controls will appear here.</div>`}</section>`}\nfunction renderRoom(r){const items=HC.devices.filter(x=>x.room_id===r.id),cats=[...new Set(items.map(x=>x.category))];const lightButton=items.some(x=>x.category===\'lighting\')?`<button class="btn" onclick="roomLights(\'${r.id}\',\'turn_off\')">All lights off</button>`:\'\';const body=items.length?cats.map(c=>section(({lighting:\'Lighting\',heating:\'Heating\',cctv:\'CCTV\',blinds:\'Blinds\',alarm:\'Alarm\',doorbell:\'Intercom\',entertainment:\'Entertainment\',security:\'Security\',switch:\'Switches\',fan:\'Fans\',scene:\'Scenes\',automation:\'Automations\'})[c]||c,items.filter(x=>x.category===c))).join(\'\'):`<div class="empty"><h2>No devices in this room</h2><div>Add devices from Settings and assign them to ${esc(r.name)}.</div><div class="controls" style="justify-content:center"><button class="btn primary" onclick="openSettings(\'devices\')">Add devices</button></div></div>`;return`<div class="page-head"><div><div class="eyebrow">Room</div><h1>${esc(r.name)}</h1><div class="muted">${items.length} connected ${items.length===1?\'device\':\'devices\'}</div></div>${lightButton}</div>${body}`}\nfunction renderScreen(){if(!HC.home)return;const s=HC.screens[HC.current]||HC.screens[0];q(\'#content\').innerHTML=s.type===\'overview\'?renderOverview():renderRoom(s)}\nasync function refresh(){setCloud(\'syncing\');try{const h=await api(\'api/v1/home\');HC.home=h;HC.devices=h.devices||[];HC.rooms=h.rooms||[];HC.screens=[{type:\'overview\'},...HC.rooms.map(room=>({type:\'room\',room}))];if(HC.current>=HC.screens.length)HC.current=0;q(\'#topHome\').textContent=h.home_name;renderScreen();renderNav();watchDoorbell();try{const c=await api(\'api/v1/cloud/status\');setCloud(c.connected?\'connected\':\'offline\')}catch{setCloud(\'offline\')}}catch(e){setCloud(\'offline\');console.error(e)}}\nasync function cmd(id,command,extra={}){try{await api(`api/v1/devices/${encodeURIComponent(id)}/command`,{method:\'POST\',body:{command,...extra}});toast(\'Command sent\');setTimeout(refresh,450)}catch(e){toast(e.message,false)}}async function roomLights(room,command){try{await api(`api/v1/rooms/${room}/command`,{method:\'POST\',body:{category:\'lighting\',command}});toast(\'Room updated\');setTimeout(refresh,450)}catch(e){toast(e.message,false)}}\nfunction watchDoorbell(){for(const d of HC.devices.filter(x=>x.category===\'doorbell\')){if(!HC.lastDoorbell[d.id]){HC.lastDoorbell[d.id]=d.last_changed;continue}if(d.last_changed&&HC.lastDoorbell[d.id]!==d.last_changed&&[\'on\',\'pressed\',\'ding\',\'ringing\'].includes(String(d.state).toLowerCase()))showRing(d);HC.lastDoorbell[d.id]=d.last_changed}}function showRing(d){q(\'#ringTitle\').textContent=d.name||\'Someone is at the door\';q(\'#ringRoom\').textContent=roomName(d.room_id);q(\'#ring\').classList.add(\'show\')}function dismissRing(){q(\'#ring\').classList.remove(\'show\')}function viewDoorbell(){dismissRing()}\n\nfunction openSettings(section=\'home\'){q(\'#settingsOverlay\').classList.add(\'open\');renderSettings(section)}function closeSettings(){q(\'#settingsOverlay\').classList.remove(\'open\')}\nfunction settingsNav(active){return`<div class="settings-nav">${[\'home\',\'rooms\',\'devices\',\'people\',\'activity\',\'controller\'].map(x=>`<button onclick="renderSettings(\'${x}\')" style="${active===x?\'border-color:var(--blue);color:var(--blue)\':\'\'}">${({home:\'Home\',rooms:\'Rooms\',devices:\'Devices\',people:\'People & access\',activity:\'Activity\',controller:\'Controller\'})[x]}</button>`).join(\'\')}</div>`}\nfunction renderSettings(section=\'home\'){const box=q(\'#settingsContent\');box.innerHTML=settingsNav(section)+`<div id="settingsBody"></div>`;const body=q(\'#settingsBody\');if(section===\'home\'){body.innerHTML=`<div class="settings-card"><h2>Home</h2><div class="field"><label>Home name</label><input id="homeName" value="${esc(HC.home.home_name)}"></div><div class="field"><label>Appearance</label><select id="themeSel"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></div><button class="btn primary" onclick="saveHomeSettings()">Save</button></div>`;q(\'#themeSel\').value=HC.home.theme||\'system\'}else if(section===\'rooms\'){body.innerHTML=`<div class="page-head"><div><h2>Rooms</h2><div class="muted">Create the rooms customers will swipe between in HomeCore.</div></div><button class="btn primary" onclick="newRoom()">Add room</button></div><div class="settings-card">${HC.rooms.length?HC.rooms.map(r=>`<div class="room-row"><div><strong>${esc(r.name)}</strong></div><div class="row-actions"><button class="btn mini" onclick="editRoom(\'${r.id}\')">Rename</button><button class="btn danger mini" onclick="deleteRoom(\'${r.id}\')">Delete</button></div></div>`).join(\'\'):\'<div class="empty">No rooms yet. Add your first room.</div>\'}</div>`}else if(section===\'devices\'){renderDeviceSettings()}else if(section===\'people\'){body.innerHTML=\'<div id="peopleBox" class="settings-card"><div class="muted">Loading HomeCore account access…</div></div>\';loadPeople()}else if(section===\'activity\'){body.innerHTML=`<div class="settings-card">${(HC.home.activity||[]).length?HC.home.activity.slice(0,60).map(a=>`<div class="activity-row"><div><strong>${esc(a.type.replaceAll(\'.\',\' \'))}</strong><div class="muted" style="font-size:11px">${esc(new Date(a.created_at).toLocaleString())}</div></div><div class="muted">${esc(a.detail?.name||a.detail?.entity_id||\'\')}</div></div>`).join(\'\'):\'<div class="empty">No HomeCore activity yet.</div>\'}</div>`}else{body.innerHTML=`<div class="settings-card"><h2>Controller</h2><div>HomeCore <strong>${esc(HC.home.version)}</strong></div><div style="margin-top:8px">Home Assistant <strong>${esc(HC.home.ha_version||\'—\')}</strong></div><div style="margin-top:8px">Added devices <strong>${HC.devices.length}</strong></div><div style="margin-top:8px">Controller <span class="muted">${esc(HC.home.controller_id||\'Not bound\')}</span></div></div>`}}\nasync function renderDeviceSettings(){\n const body=q(\'#settingsBody\');\n body.innerHTML=`<div class="page-head"><div><h2>Devices</h2><div class="muted">Set up new products directly in HomeCore, then choose which devices appear in your rooms.</div></div><button class="btn primary" onclick="openDeviceCatalogue()">+ Add device</button></div>\n <div class="settings-card"><h3>My devices</h3><div id="myDevices">${HC.devices.length?HC.devices.map(myDeviceRow).join(\'\'):\'<div class="empty">No devices have been added to HomeCore yet. Press + Add device to set up your first product.</div>\'}</div></div>\n <section class="section"><div class="settings-card"><div class="page-head" style="margin-top:0"><div><h3>Already connected devices</h3><div class="muted">If a device was previously connected to this Green, HomeCore can find it here.</div></div><button class="btn" onclick="scanDevices()">Find devices</button></div><div id="discoveryBox"></div></div></section>`;\n}\nfunction myDeviceRow(d){return`<div class="device-row"><div><strong>${esc(d.name)}</strong><div class="muted" style="font-size:11px">${esc(d.category_label)} · ${esc(d.entity_id)}</div></div><div class="row-actions"><select onchange="assignRoom(\'${esc(d.id)}\',this.value)" style="background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:7px"><option value="">Unassigned</option>${HC.rooms.map(r=>`<option value="${r.id}" ${d.room_id===r.id?\'selected\':\'\'}>${esc(r.name)}</option>`).join(\'\')}</select><button class="btn mini" onclick="toggleFavorite(\'${esc(d.id)}\',${!d.favorite})">${d.favorite?\'★\':\'☆\'}</button><button class="btn mini" onclick="renameDevice(\'${esc(d.id)}\',\'${esc(d.name).replaceAll("\'","&#39;")}\')">Rename</button><button class="btn danger mini" onclick="removeDevice(\'${esc(d.id)}\')">Remove</button></div></div>`}\n\nasync function openDeviceCatalogue(){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><div><div class="eyebrow">HomeCore devices</div><h2>Add device</h2></div><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="empty">Loading supported products…</div>`;\n q(\'#modalWrap\').classList.add(\'open\');\n try{\n const d=await api(\'api/v1/device-catalog\');\n HC.catalog=d.catalog||[];\n renderCatalogue();\n }catch(e){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Add device</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="empty" style="color:var(--red)">${esc(e.message)}</div>`;\n }\n}\nfunction renderCatalogue(){\n const brands=HC.catalog||[];\n q(\'#modal\').innerHTML=`<div class="drawer-head"><div><div class="eyebrow">HomeCore devices</div><h2>Add device</h2></div><button class="icon-btn" onclick="closeModal()">✕</button></div>\n <p class="muted">Choose the product family. HomeCore will guide the setup without sending you into Home Assistant.</p>\n <div class="catalog-grid">${brands.map(catalogCard).join(\'\')}</div>`;\n}\nfunction catalogCard(b){\n const ready=(b.variants||[]).some(v=>v.available);\n return `<div class="catalog-card ${ready?\'ready\':\'\'}"><div class="catalog-name">${esc(b.name)}</div><div class="catalog-cat">${esc(b.category)}</div><div class="catalog-desc">${esc(b.description)}</div><div>${(b.capabilities||[]).map(x=>`<span class="tag">${esc(x)}</span>`).join(\'\')}</div><div class="catalog-status ${ready?\'ready\':\'off\'}">${ready?\'Ready to set up\':\'Driver required\'}</div><button class="btn ${ready?\'primary\':\'\'}" onclick="chooseBrand(\'${esc(b.id)}\')">Choose</button></div>`;\n}\nfunction chooseBrand(id){\n const brand=(HC.catalog||[]).find(x=>x.id===id);if(!brand)return;\n const variants=brand.variants||[];\n if(variants.length===1){startVariant(brand.id,variants[0].id);return}\n q(\'#modal\').innerHTML=`<div class="drawer-head"><div><div class="eyebrow">${esc(brand.category)}</div><h2>${esc(brand.name)}</h2></div><button class="icon-btn" onclick="renderCatalogue()">←</button></div><div class="muted" style="margin-bottom:14px">${esc(brand.description)}</div>${variants.map(v=>`<div class="variant-row"><strong>${esc(v.name)}</strong><div class="muted" style="font-size:12px;margin:5px 0 10px">${esc(v.note||\'\')}</div><div class="catalog-status ${v.available?\'ready\':\'off\'}">${v.available?\'Ready to set up\':esc(v.unavailable||\'Driver required\')}</div><button class="btn ${v.available?\'primary\':\'\'}" onclick="startVariant(\'${esc(brand.id)}\',\'${esc(v.id)}\')">${v.available?\'Set up\':\'Check driver\'}</button></div>`).join(\'\')}`;\n}\nasync function startVariant(brandId,variantId){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Setting up device</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="empty">Starting HomeCore device setup…</div>`;\n try{\n const r=await api(\'api/v1/device-setup/start\',{method:\'POST\',body:{brand_id:brandId,variant_id:variantId}});\n HC.flowBrand=r.brand;HC.flowVariant=r.variant;\n renderFlow(r.step);\n }catch(e){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><div><h2>Driver required</h2></div><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="empty">${esc(e.message)}</div><div class="controls"><button class="btn" onclick="openDeviceCatalogue()">Back to devices</button></div>`;\n }\n}\nfunction fieldLabel(name){return String(name||\'\').replaceAll(\'_\',\' \').replace(/\\b\\w/g,c=>c.toUpperCase()).replace(\'Host\',\'IP address / hostname\').replace(\'Username\',\'Email / username\')}\nfunction flowField(f){\n if(f.type===\'expandable\'&&Array.isArray(f.schema))return `<div class="settings-card"><strong>${esc(fieldLabel(f.name))}</strong>${f.schema.map(flowField).join(\'\')}</div>`;\n const n=esc(f.name),label=esc(fieldLabel(f.name)),req=f.required?\'required\':\'\';\n const suggested=f.default ?? f.description?.suggested_value ?? \'\';\n const selector=f.selector||{};\n if(f.type===\'boolean\'||selector.boolean)return `<label style="display:block;margin:10px 0"><input data-flow="${n}" type="checkbox" ${suggested===true?\'checked\':\'\'}> ${label}</label>`;\n const options=f.options || selector.select?.options;\n if(options){\n const vals=Array.isArray(options)?options:Object.keys(options);\n return `<div class="field"><label>${label}${f.required?\' *\':\'\'}</label><select data-flow="${n}" ${req}>${vals.map(o=>{const value=typeof o===\'object\'?(o.value??o.label):o;const text=typeof o===\'object\'?(o.label??o.value):o;return `<option value="${esc(value)}">${esc(text)}</option>`}).join(\'\')}</select></div>`;\n }\n const numeric=f.type===\'integer\'||f.type===\'float\'||selector.number;\n const secret=/pass|token|secret|key/i.test(f.name);\n return `<div class="field"><label>${label}${f.required?\' *\':\'\'}</label><input data-flow="${n}" type="${secret?\'password\':numeric?\'number\':\'text\'}" value="${esc(suggested)}" ${numeric?\'step="any"\':\'\'} ${req}></div>`;\n}\nfunction collectFlowData(){\n const out={};\n document.querySelectorAll(\'[data-flow]\').forEach(el=>{\n if(el.type===\'checkbox\')out[el.dataset.flow]=el.checked;\n else if(el.type===\'number\'&&el.value!==\'\')out[el.dataset.flow]=Number(el.value);\n else if(el.value!==\'\')out[el.dataset.flow]=el.value;\n });\n return out;\n}\nfunction renderFlow(step){\n if(!step){q(\'#modal\').innerHTML=\'<div class="empty">Device setup returned no information.</div>\';return}\n HC.flowStep=step;\n const type=step.type||\'form\';\n if(type===\'form\'){\n const errs=step.errors||{};\n q(\'#modal\').innerHTML=`<div class="drawer-head"><div><div class="eyebrow">${esc(HC.flowBrand||\'HomeCore\')}</div><h2>${esc(HC.flowVariant||\'Device setup\')}</h2></div><button class="icon-btn" onclick="cancelFlow()">✕</button></div>\n <div class="muted" style="margin-bottom:12px">${esc(step.description_placeholders?.description||\'Follow the steps below to connect the device.\')}</div>\n ${Object.keys(errs).length?`<div class="flow-error">${esc(Object.entries(errs).map(([k,v])=>`${fieldLabel(k)}: ${v}`).join(\' · \'))}</div>`:\'\'}\n <div class="flow-fields">${(step.data_schema||[]).map(flowField).join(\'\')}</div>\n <button class="btn primary" onclick="submitFlow()">Continue</button>`;\n return;\n }\n if(type===\'menu\'){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>${esc(HC.flowVariant||\'Device setup\')}</h2><button class="icon-btn" onclick="cancelFlow()">✕</button></div><div class="muted" style="margin-bottom:12px">Choose how you want to continue.</div>${(step.menu_options||[]).map(x=>`<button class="btn" style="display:block;width:100%;margin:8px 0" onclick="submitFlow({next_step_id:\'${esc(x)}\'})">${esc(fieldLabel(x))}</button>`).join(\'\')}`;\n return;\n }\n if(type===\'external\'){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>${esc(HC.flowVariant||\'Device setup\')}</h2><button class="icon-btn" onclick="cancelFlow()">✕</button></div><p class="muted">This product needs a secure manufacturer sign-in or approval step.</p><a class="btn primary" style="display:inline-block;text-decoration:none" href="${esc(step.url||\'#\')}" target="_blank" rel="noopener">Open secure sign-in</a><div class="controls"><button class="btn" onclick="refreshFlow()">I\'ve completed sign-in</button></div>`;\n return;\n }\n if(type===\'external_done\'||type===\'progress\'){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>${esc(HC.flowVariant||\'Device setup\')}</h2><button class="icon-btn" onclick="cancelFlow()">✕</button></div><div class="empty">Finishing device setup…</div>`;\n setTimeout(refreshFlow,1800);return;\n }\n if(type===\'create_entry\'){\n q(\'#modal\').innerHTML=`<div class="drawer-head"><div><div class="eyebrow">Connected</div><h2>${esc(step.title||HC.flowVariant||\'Device added\')}</h2></div><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="empty">The product is now connected to HomeCore. HomeCore can now find the devices it provides and add them to your rooms.</div><div class="controls"><button class="btn primary" onclick="finishFlowAndFind()">Find its devices</button></div>`;\n return;\n }\n if(type===\'abort\'){\n const reason=String(step.reason||\'Setup ended\').replaceAll(\'_\',\' \');\n q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>${esc(HC.flowVariant||\'Device setup\')}</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="empty">${esc(reason)}</div><div class="controls"><button class="btn" onclick="openDeviceCatalogue()">Back to devices</button></div>`;\n return;\n }\n q(\'#modal\').innerHTML=`<div class="empty">HomeCore received an unsupported setup step: ${esc(type)}</div>`;\n}\nasync function submitFlow(data=null){\n const step=HC.flowStep;if(!step?.flow_id)return;\n try{const next=await api(`api/v1/device-setup/flow/${encodeURIComponent(step.flow_id)}`,{method:\'POST\',body:data||collectFlowData()});renderFlow(next)}\n catch(e){toast(e.message,false)}\n}\nasync function refreshFlow(){\n const id=HC.flowStep?.flow_id;if(!id)return;\n try{renderFlow(await api(`api/v1/device-setup/flow/${encodeURIComponent(id)}`))}\n catch(e){toast(e.message,false)}\n}\nasync function cancelFlow(){\n const id=HC.flowStep?.flow_id;\n if(id){try{await api(`api/v1/device-setup/flow/${encodeURIComponent(id)}`,{method:\'DELETE\'})}catch{}}\n closeModal();\n}\nasync function finishFlowAndFind(){\n closeModal();renderSettings(\'devices\');\n setTimeout(scanDevices,1800);\n}\nasync function scanDevices(){\n const box=q(\'#discoveryBox\');if(!box)return;\n box.innerHTML=\'<div class="empty">Finding devices…</div>\';\n try{const d=await api(\'api/v1/device-discovery\');HC.discovered=d.devices||[];renderDiscovery(d)}\n catch(e){box.innerHTML=`<div class="empty" style="color:var(--red)">Could not find devices: ${esc(e.message)}</div>`}\n}\nfunction renderDiscovery(info){\n const box=q(\'#discoveryBox\'),available=(info.devices||[]).filter(x=>!x.added);\n if(!available.length){box.innerHTML=`<div class="empty">${info.supported_count===0?`The controller returned ${info.total_states||0} entities, but none are currently usable HomeCore device types.`:\'All supported devices found by this controller are already in HomeCore.\'}</div>`;return}\n box.innerHTML=`<input class="search" id="deviceSearch" placeholder="Search devices" oninput="filterDiscovery()"><div id="discoveryGrid" class="discovery-grid">${available.map(discoveryCard).join(\'\')}</div>`;\n}\nfunction discoveryCard(d){return`<div class="discover-card" data-search="${esc((d.name+\' \'+d.entity_id+\' \'+d.category_label).toLowerCase())}"><div class="discover-name">${esc(d.name)}</div><div class="discover-meta">${esc(d.category_label)}<br>State: ${esc(d.state)}</div><button class="btn primary mini" onclick="addDeviceModal(\'${esc(d.id)}\')">Add to HomeCore</button></div>`}\nfunction filterDiscovery(){const s=q(\'#deviceSearch\').value.toLowerCase();document.querySelectorAll(\'.discover-card\').forEach(x=>x.style.display=x.dataset.search.includes(s)?\'block\':\'none\')}\nfunction addDeviceModal(id){const d=HC.discovered.find(x=>x.id===id);q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Add to HomeCore</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="muted" style="margin-bottom:12px">${esc(d.category_label)}</div><div class="field"><label>HomeCore name</label><input id="addDeviceName" value="${esc(d.name)}"></div><div class="field"><label>Room</label><select id="addDeviceRoom"><option value="">Unassigned</option>${HC.rooms.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join(\'\')}</select></div><label style="display:block;margin:12px 0"><input id="addFavorite" type="checkbox"> Add to favourites</label><button class="btn primary" onclick="saveAddedDevice(\'${esc(d.id)}\')">Add to HomeCore</button>`;q(\'#modalWrap\').classList.add(\'open\')}\nasync function saveAddedDevice(id){try{await api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,added:true,name:q(\'#addDeviceName\').value,room_id:q(\'#addDeviceRoom\').value,favorite:q(\'#addFavorite\').checked}});closeModal();toast(\'Device added\');await refresh();renderSettings(\'devices\')}catch(e){toast(e.message,false)}}\nasync function removeDevice(id){if(!confirm(\'Remove this device from HomeCore? The physical device will stay connected.\'))return;try{await api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,added:false,room_id:\'\',favorite:false}});toast(\'Removed from HomeCore\');await refresh();renderSettings(\'devices\')}catch(e){toast(e.message,false)}}\nasync function saveHomeSettings(){try{const v=await api(\'api/v1/settings\',{method:\'POST\',body:{home_name:q(\'#homeName\').value,theme:q(\'#themeSel\').value}});applyTheme(v.theme);toast(\'Settings saved\');await refresh();renderSettings(\'home\')}catch(e){toast(e.message,false)}}function applyTheme(t){document.documentElement.dataset.theme=t===\'system\'?(matchMedia(\'(prefers-color-scheme: light)\').matches?\'light\':\'dark\'):t}\nfunction newRoom(){q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Add room</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="field"><label>Room name</label><input id="roomName" placeholder="Living room"></div><button class="btn primary" onclick="createRoom()">Create room</button>`;q(\'#modalWrap\').classList.add(\'open\')}async function createRoom(){try{await api(\'api/v1/rooms\',{method:\'POST\',body:{name:q(\'#roomName\').value}});closeModal();await refresh();renderSettings(\'rooms\');toast(\'Room created\')}catch(e){toast(e.message,false)}}function editRoom(id){const r=HC.rooms.find(x=>x.id===id);q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Rename room</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="field"><label>Room name</label><input id="roomName" value="${esc(r.name)}"></div><button class="btn primary" onclick="saveRoom(\'${id}\')">Save</button>`;q(\'#modalWrap\').classList.add(\'open\')}async function saveRoom(id){try{await api(`api/v1/rooms/${id}`,{method:\'PUT\',body:{name:q(\'#roomName\').value}});closeModal();await refresh();renderSettings(\'rooms\');toast(\'Room saved\')}catch(e){toast(e.message,false)}}async function deleteRoom(id){if(!confirm(\'Delete this room? Devices will become unassigned.\'))return;try{await api(`api/v1/rooms/${id}`,{method:\'DELETE\'});await refresh();renderSettings(\'rooms\');toast(\'Room deleted\')}catch(e){toast(e.message,false)}}\nasync function assignRoom(id,room){try{await api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,room_id:room,added:true}});await refresh();renderSettings(\'devices\')}catch(e){toast(e.message,false)}}async function toggleFavorite(id,v){try{await api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,favorite:v,added:true}});await refresh();renderSettings(\'devices\')}catch(e){toast(e.message,false)}}function renameDevice(id,name){const v=prompt(\'HomeCore device name\',name);if(v!==null)api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,name:v,added:true}}).then(async()=>{await refresh();renderSettings(\'devices\')}).catch(e=>toast(e.message,false))}\nasync function loadPeople(){const box=q(\'#peopleBox\');if(!box)return;try{const data=await api(\'api/v1/cloud/access\');box.innerHTML=`<div class="page-head" style="margin-top:0"><div><strong>${data.owner?\'Owner account\':\'HomeCore account connected\'}</strong><div class="muted" style="font-size:12px">${esc(data.email||\'\')}</div></div>${data.owner?\'<button class="btn primary" onclick="sharePerson()">Share access</button>\':\'\'}</div>${(data.members||[]).map(m=>`<div class="person-row"><div><strong>${esc(m.display_name||m.email)}</strong><div class="muted" style="font-size:11px">${esc(m.email)}</div><div>${(m.permissions||[]).map(p=>`<span class="tag">${esc(p)}</span>`).join(\'\')}</div></div>${data.owner?`<button class="btn danger mini" onclick="revokePerson(\'${m.user_id}\')">Remove</button>`:\'\'}</div>`).join(\'\')||\'<div class="muted">No shared users.</div>\'}`}catch(e){box.innerHTML=`<div class="muted">Connect the Owner HomeCore account to manage sharing and permissions.</div><div class="controls"><button class="btn primary" onclick="cloudLoginModal()">Connect account</button></div>`}}\nfunction cloudLoginModal(){q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Connect HomeCore account</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="field"><label>Email</label><input id="cloudEmail" type="email"></div><div class="field"><label>Password</label><input id="cloudPassword" type="password"></div><button class="btn primary" onclick="cloudLogin()">Sign in</button>`;q(\'#modalWrap\').classList.add(\'open\')}async function cloudLogin(){try{await api(\'api/v1/session/login\',{method:\'POST\',body:{email:q(\'#cloudEmail\').value,password:q(\'#cloudPassword\').value}});closeModal();toast(\'HomeCore account connected\');loadPeople()}catch(e){toast(e.message,false)}}\nasync function sharePerson(){try{const d=await api(\'api/v1/cloud/permission-options\');const opts=(d.permissions||[]).map(p=>`<label style="display:block;margin:7px 0"><input type="checkbox" class="perm" value="${esc(p)}"> ${esc(p.replaceAll(\'_\',\' \'))}</label>`).join(\'\');q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Share this home</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="field"><label>Existing HomeCore account email</label><input id="shareEmail" type="email"></div><div class="field"><label>Permissions</label>${opts}</div><button class="btn primary" onclick="saveShare()">Share access</button>`;q(\'#modalWrap\').classList.add(\'open\')}catch(e){toast(e.message,false)}}async function saveShare(){try{const permissions=[...document.querySelectorAll(\'.perm:checked\')].map(x=>x.value);await api(\'api/v1/cloud/share\',{method:\'POST\',body:{email:q(\'#shareEmail\').value,permissions}});closeModal();toast(\'Access updated\');loadPeople()}catch(e){toast(e.message,false)}}async function revokePerson(id){if(!confirm(\'Remove this person from the home?\'))return;try{await api(`api/v1/cloud/members/${encodeURIComponent(id)}`,{method:\'DELETE\'});toast(\'Access removed\');loadPeople()}catch(e){toast(e.message,false)}}\nfunction closeModal(){q(\'#modalWrap\').classList.remove(\'open\')}function showLogin(){q(\'#bottomNav\').innerHTML=\'\';q(\'#content\').innerHTML=`<div class="login"><div class="login-card"><div class="logo">Home<span>Core</span></div><h1 style="font-size:30px;margin-top:24px">Welcome home</h1><p class="muted">Sign in with your HomeCore account to use this controller locally.</p><label>Email</label><input id="loginEmail" type="email"><label>Password</label><input id="loginPassword" type="password"><button class="btn primary" style="width:100%" onclick="directLogin()">Sign in</button><div id="loginError" style="color:var(--red);font-size:12px;margin-top:10px"></div></div></div>`}async function directLogin(){try{const r=await fetch(BASE+\'api/v1/session/login\',{method:\'POST\',headers:{\'Content-Type\':\'application/json\'},body:JSON.stringify({email:q(\'#loginEmail\').value,password:q(\'#loginPassword\').value})});if(!r.ok)throw new Error((await r.json()).detail||\'Sign in failed\');await start()}catch(e){q(\'#loginError\').textContent=e.message}}\nasync function start(){try{const h=await api(\'api/v1/home\');applyTheme(h.theme||\'system\');await refresh();if(HC.poll)clearInterval(HC.poll);HC.poll=setInterval(refresh,5000)}catch(e){showLogin()}}\nq(\'#modalWrap\').addEventListener(\'click\',e=>{if(e.target===q(\'#modalWrap\'))closeModal()});start();\n</script>\n</body>\n</html>'
def build_router(version, data_dir, load_state, options, ha_version):
router = APIRouter()
product_path = data_dir / "homecore_product.json"
activity_path = data_dir / "homecore_activity.json"
def read_json(path, default):
try:
return json.loads(path.read_text())
except Exception:
return default
def atomic_json(path, value):
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
os.replace(tmp, path)
def default_product():
return {
"schema": 1,
"home_name": "",
"theme": "system",
"rooms": [],
"devices": {},
}
def load_product():
current = read_json(product_path, default_product())
base = default_product()
if isinstance(current, dict):
base.update(current)
if not isinstance(base.get("rooms"), list):
base["rooms"] = []
if not isinstance(base.get("devices"), dict):
base["devices"] = {}
return base
def save_product(value):
atomic_json(product_path, value)
def activity(event_type, detail=None, actor="local"):
with LOCK:
rows = read_json(activity_path, [])
if not isinstance(rows, list):
rows = []
rows.insert(
0,
{
"id": uuid.uuid4().hex,
"type": event_type,
"detail": detail or {},
"actor": actor,
"created_at": datetime.now(timezone.utc).isoformat(),
},
)
atomic_json(activity_path, rows[:500])
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 ha_request(method, path, payload=None, params=None, timeout=20):
r = httpx.request(
method,
CORE_API + path,
headers=supervisor_headers(),
json=payload,
params=params,
timeout=timeout,
)
r.raise_for_status()
if not r.content:
return None
return r.json()
def ha_binary(path, timeout=30):
r = httpx.get(
CORE_API + path,
headers=supervisor_headers(),
timeout=timeout,
)
r.raise_for_status()
return r
def ingress_context(request):
user_id = request.headers.get("x-remote-user-id")
if not user_id:
return None
return {
"mode": "ingress",
"owner": True,
"permissions": ["*"],
"user": {
"id": user_id,
"display_name": request.headers.get(
"x-remote-user-display-name",
request.headers.get("x-remote-user-name", "Home Assistant"),
),
},
}
def local_session(request):
sid = request.cookies.get("hc_session") or request.headers.get("x-homecore-session")
if not sid:
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
sid = auth.split(" ", 1)[1]
value = SESSIONS.get(sid or "")
if not value:
return None
if value["expires"] < time.time():
SESSIONS.pop(sid, None)
return None
return {"sid": sid, **value, "mode": "homecore"}
def auth_context(request, required=True):
local = local_session(request)
if local:
prop = local.get("property") or {}
return {
"mode": "homecore",
"owner": bool(prop.get("owner")),
"permissions": list(prop.get("permissions") or []),
"user": local.get("user") or {},
"session": local,
}
ingress = ingress_context(request)
if ingress:
return ingress
if required:
raise HTTPException(401, "HomeCore sign in required")
return None
def cloud_headers(ctx):
session = (ctx or {}).get("session") or {}
token = session.get("cloud_token")
if not token:
raise HTTPException(
401,
"Connect a HomeCore account to manage cloud access",
)
return {"Authorization": "Bearer " + token}
def permission_words(category):
return {
"lighting": ["light", "lighting"],
"heating": ["heat", "heating", "climate"],
"cctv": ["camera", "cctv"],
"blinds": ["cover", "blind", "blinds"],
"alarm": ["alarm", "security"],
"doorbell": ["doorbell", "intercom"],
"entertainment": ["media", "entertainment", "tv"],
"security": ["lock", "security"],
"switch": ["switch"],
"fan": ["fan"],
"scene": ["scene"],
"automation": ["automation"],
}.get(category, [category])
def can_control(ctx, category):
if ctx.get("owner") or ctx.get("mode") == "ingress":
return True
perms = [str(x).lower() for x in ctx.get("permissions", [])]
if "*" in perms:
return True
if any(
p in {
"control", "control_all", "control_devices",
"devices.control", "home.control",
}
for p in perms
):
return True
words = permission_words(category)
return any(
any(w in p for w in words)
and ("control" in p or "manage" in p or p in words)
for p in perms
)
def can_view(ctx, category):
if can_control(ctx, category):
return True
if ctx.get("owner") or ctx.get("mode") == "ingress":
return True
perms = [str(x).lower() for x in ctx.get("permissions", [])]
if any(
p in {"view", "view_all", "view_devices", "home.view"}
for p in perms
):
return True
words = permission_words(category)
return any(
any(w in p for w in words)
and ("view" in p or "read" in p)
for p in perms
) or bool(perms)
def category_for(entity_id, attrs):
domain = entity_id.split(".", 1)[0]
device_class = str(attrs.get("device_class") or "").lower()
if domain == "light":
return "lighting"
if domain in ("climate", "water_heater"):
return "heating"
if domain == "camera":
return "cctv"
if domain == "cover":
return "blinds"
if domain == "alarm_control_panel":
return "alarm"
if domain == "media_player":
return "entertainment"
if domain == "lock":
return "security"
if domain == "switch":
return "switch"
if domain == "fan":
return "fan"
if domain == "scene":
return "scene"
if domain in ("script", "automation"):
return "automation"
if domain == "event" and device_class == "doorbell":
return "doorbell"
if domain == "binary_sensor":
label = (
entity_id + " " + str(attrs.get("friendly_name") or "")
).lower()
if device_class == "doorbell" or "doorbell" in label:
return "doorbell"
if device_class in (
"door", "window", "opening", "motion", "occupancy",
"tamper", "lock", "problem", "safety", "smoke", "gas",
):
return "security"
return "sensor"
if domain == "sensor":
if device_class == "temperature":
return "heating"
return "sensor"
if domain in ("number", "select"):
return "sensor"
if domain == "button" and "doorbell" in entity_id.lower():
return "doorbell"
if domain == "remote":
return "entertainment"
return "other"
def capabilities(entity_id, attrs):
domain = entity_id.split(".", 1)[0]
if domain == "light":
caps = ["power"]
if "brightness" in attrs or attrs.get("supported_color_modes"):
caps.append("brightness")
modes = attrs.get("supported_color_modes") or []
if any(x in modes for x in ("rgb", "rgbw", "rgbww", "hs", "xy")):
caps.append("color")
if any(x in modes for x in ("color_temp", "color_temp_kelvin")):
caps.append("color_temperature")
return caps
if domain in ("climate", "water_heater"):
return ["temperature", "mode"]
if domain == "cover":
caps = ["open", "close", "stop"]
if attrs.get("current_position") is not None:
caps.append("position")
return caps
if domain == "alarm_control_panel":
return ["arm_home", "arm_away", "arm_night", "disarm"]
if domain == "media_player":
caps = ["power", "play_pause", "volume"]
if attrs.get("source_list"):
caps.append("source")
return caps
if domain == "switch":
return ["power"]
if domain == "lock":
return ["lock", "unlock"]
if domain == "fan":
return ["power", "percentage"]
if domain == "scene":
return ["activate"]
if domain == "script":
return ["run"]
if domain == "automation":
return ["enable", "trigger"]
if domain == "button":
return ["press"]
if domain == "remote":
return ["power"]
if domain == "vacuum":
return ["start", "pause", "stop", "return_home"]
if domain == "camera":
return ["view"]
return []
def normalize(raw, product, include_unadded=False):
entity_id = str(raw.get("entity_id") or "")
if "." not in entity_id:
return None
domain = entity_id.split(".", 1)[0]
if domain not in DEVICE_DOMAINS:
return None
attrs = raw.get("attributes") or {}
category = category_for(entity_id, attrs)
# Event entities are only customer-facing when they represent
# doorbell/intercom activity. Binary sensors are retained because
# alarm ecosystems expose zones and detectors through them.
if category == "other" and domain == "event":
return None
override = (product.get("devices") or {}).get(entity_id, {})
# A device becomes part of HomeCore only when the customer explicitly
# adds it. Legacy configured entries from 1.0.1 count as added.
added = bool(override.get("added"))
if override and "added" not in override:
added = True
if not include_unadded and not added:
return None
if not include_unadded and override.get("hidden"):
return None
name = (
override.get("name")
or attrs.get("friendly_name")
or entity_id.split(".", 1)[1].replace("_", " ").title()
)
item = {
"id": entity_id,
"entity_id": entity_id,
"domain": domain,
"category": category,
"category_label": CATEGORY_LABELS.get(category, "Other"),
"name": name,
"state": raw.get("state"),
"room_id": override.get("room_id") or "",
"favorite": bool(override.get("favorite")),
"added": added,
"available": raw.get("state") not in ("unavailable", "unknown"),
"last_changed": raw.get("last_changed"),
"last_updated": raw.get("last_updated"),
"capabilities": capabilities(entity_id, attrs),
"attributes": {},
}
for key in (
"brightness", "rgb_color", "color_temp_kelvin",
"current_temperature", "temperature", "target_temp_high",
"target_temp_low", "hvac_action", "hvac_modes",
"current_position", "current_tilt_position", "volume_level",
"is_volume_muted", "media_title", "media_artist", "source",
"source_list", "app_name", "friendly_name", "device_class",
"event_type", "event_types", "percentage", "preset_mode",
"preset_modes",
):
if key in attrs:
item["attributes"][key] = attrs[key]
if domain == "camera":
item["camera_url"] = (
"/api/v1/camera/" + quote(entity_id, safe="")
)
return item
def all_homeassistant_states():
states = ha_request("GET", "/states") or []
if not isinstance(states, list):
raise RuntimeError("Home Assistant did not return an entity-state list")
return states
def get_devices(ctx=None):
product = load_product()
out = []
for raw in all_homeassistant_states():
item = normalize(raw, product, include_unadded=False)
if not item:
continue
if ctx and not can_view(ctx, item["category"]):
continue
out.append(item)
out.sort(
key=lambda x: (
x["room_id"],
x["category"],
x["name"].lower(),
)
)
return out
def get_discovered_devices(ctx=None):
product = load_product()
states = all_homeassistant_states()
out = []
for raw in states:
item = normalize(raw, product, include_unadded=True)
if not item:
continue
if ctx and not can_view(ctx, item["category"]):
continue
out.append(item)
out.sort(
key=lambda x: (
0 if x.get("added") else 1,
x["category"],
x["name"].lower(),
)
)
return states, out
def overview(devices):
lighting = [x for x in devices if x["category"] == "lighting"]
heating = [x for x in devices if x["category"] == "heating"]
media = [x for x in devices if x["category"] == "entertainment"]
alarms = [x for x in devices if x["category"] == "alarm"]
locks = [x for x in devices if x["category"] == "security"]
cameras = [x for x in devices if x["category"] == "cctv"]
temps = []
for item in heating:
value = item["attributes"].get("current_temperature")
if isinstance(value, (int, float)):
temps.append(float(value))
return {
"lights_on": sum(1 for x in lighting if x["state"] == "on"),
"lights_total": len(lighting),
"average_temperature": (
round(sum(temps) / len(temps), 1) if temps else None
),
"media_active": sum(
1
for x in media
if x["state"] in ("on", "playing", "paused")
),
"cameras": len(cameras),
"alarm_state": alarms[0]["state"] if alarms else None,
"unlocked": sum(
1 for x in locks if x["state"] == "unlocked"
),
}
def find_device(entity_id, ctx):
for item in get_devices(ctx):
if item["id"] == entity_id:
return item
raise HTTPException(404, "Home Assistant entity not found")
def safe_service(domain, command, body):
mapping = {
"light": {
"turn_on": "turn_on",
"turn_off": "turn_off",
"toggle": "toggle",
},
"climate": {
"set_temperature": "set_temperature",
"set_mode": "set_hvac_mode",
"turn_on": "turn_on",
"turn_off": "turn_off",
},
"water_heater": {
"set_temperature": "set_temperature",
"set_mode": "set_operation_mode",
"turn_on": "turn_on",
"turn_off": "turn_off",
},
"cover": {
"open": "open_cover",
"close": "close_cover",
"stop": "stop_cover",
"set_position": "set_cover_position",
},
"alarm_control_panel": {
"arm_home": "alarm_arm_home",
"arm_away": "alarm_arm_away",
"arm_night": "alarm_arm_night",
"disarm": "alarm_disarm",
},
"media_player": {
"turn_on": "turn_on",
"turn_off": "turn_off",
"play_pause": "media_play_pause",
"play": "media_play",
"pause": "media_pause",
"next": "media_next_track",
"previous": "media_previous_track",
"volume": "volume_set",
"mute": "volume_mute",
"source": "select_source",
},
"switch": {
"turn_on": "turn_on",
"turn_off": "turn_off",
"toggle": "toggle",
},
"lock": {"lock": "lock", "unlock": "unlock"},
"fan": {
"turn_on": "turn_on",
"turn_off": "turn_off",
"toggle": "toggle",
"percentage": "set_percentage",
},
"scene": {"activate": "turn_on"},
"script": {"run": "turn_on"},
"automation": {
"enable": "turn_on",
"disable": "turn_off",
"trigger": "trigger",
},
"button": {"press": "press"},
"remote": {
"turn_on": "turn_on",
"turn_off": "turn_off",
},
"vacuum": {
"start": "start",
"pause": "pause",
"stop": "stop",
"return_home": "return_to_base",
},
}
service = mapping.get(domain, {}).get(command)
if not service:
raise HTTPException(
400,
f"Unsupported {domain} command: {command}",
)
data = {"entity_id": body["entity_id"]}
allowed = {
"brightness_pct", "rgb_color", "color_temp_kelvin",
"temperature", "hvac_mode", "operation_mode", "position",
"code", "volume_level", "is_volume_muted", "source",
"percentage", "preset_mode", "skip_condition",
}
aliases = {
"mode": "hvac_mode",
"cover_position": "position",
"volume": "volume_level",
}
for key, value in body.items():
target = aliases.get(key, key)
if target in allowed:
data[target] = value
return service, data
@router.post("/api/v1/session/login")
async def session_login(request: Request):
body = await request.json()
email = str(body.get("email") or "").strip().lower()
password = str(body.get("password") or "")
if not email or not password:
raise HTTPException(400, "Email and password are required")
api = options()["cloud_api"].rstrip("/")
with httpx.Client(timeout=20) as client:
response = client.post(
api + "/api/v1/auth/login",
json={"email": email, "password": password},
)
if response.status_code >= 400:
raise HTTPException(
401,
"Incorrect HomeCore email address or password",
)
login = response.json()
token = login["access_token"]
headers = {"Authorization": "Bearer " + token}
props = client.get(
api + "/api/v1/properties",
headers=headers,
)
if props.status_code >= 400:
raise HTTPException(
props.status_code,
"HomeCore Cloud rejected property access",
)
property_id = load_state().get("property_id")
prop = next(
(x for x in props.json() if x.get("id") == property_id),
None,
)
if not prop:
raise HTTPException(
403,
"This HomeCore account does not have access to this home",
)
sid = secrets.token_urlsafe(32)
SESSIONS[sid] = {
"cloud_token": token,
"user": login.get("user") or {"email": email},
"property": prop,
"expires": time.time() + (7 * 24 * 3600),
}
response = JSONResponse(
{
"ok": True,
"user": login.get("user"),
"owner": bool(prop.get("owner")),
"permissions": prop.get("permissions") or [],
}
)
response.set_cookie(
"hc_session",
sid,
httponly=True,
samesite="lax",
max_age=7 * 24 * 3600,
secure=False,
)
return response
@router.post("/api/v1/session/logout")
def session_logout(request: Request):
session = local_session(request)
if session:
SESSIONS.pop(session["sid"], None)
response = JSONResponse({"ok": True})
response.delete_cookie("hc_session")
return response
@router.get("/api/v1/home")
def home(request: Request):
ctx = auth_context(request)
product = load_product()
devices = get_devices(ctx)
state = load_state()
home_name = (
product.get("home_name")
or state.get("property_name")
or "HomeCore Home"
)
rooms = sorted(
product.get("rooms", []),
key=lambda x: (
x.get("order", 999),
x.get("name", "").lower(),
),
)
return {
"version": version,
"ha_version": ha_version(),
"controller_id": state.get("controller_id"),
"property_id": state.get("property_id"),
"home_name": home_name,
"theme": product.get("theme", "system"),
"rooms": rooms,
"devices": devices,
"overview": overview(devices),
"activity": read_json(activity_path, [])[:60],
"user": ctx.get("user"),
"owner": bool(ctx.get("owner")),
"permissions": ctx.get("permissions", []),
}
@router.get("/api/v1/devices")
def devices(request: Request):
ctx = auth_context(request)
return {"devices": get_devices(ctx)}
@router.get("/api/v1/device-discovery")
def device_discovery(request: Request):
ctx = auth_context(request)
require_owner(ctx)
states, discovered = get_discovered_devices(ctx)
return {
"total_states": len(states),
"supported_count": len(discovered),
"added_count": sum(1 for x in discovered if x.get("added")),
"devices": discovered,
}
def flow_handlers():
handlers = ha_request(
"GET",
"/config/config_entries/flow_handlers",
timeout=30,
) or []
return set(str(x) for x in handlers)
def public_catalog():
handlers = flow_handlers()
result = []
for brand in HOMECORE_DEVICE_CATALOG:
item = {
key: value
for key, value in brand.items()
if key != "variants"
}
variants = []
for variant in brand.get("variants", []):
chosen = next(
(
handler
for handler in variant.get("handlers", [])
if handler in handlers
),
None,
)
variants.append(
{
"id": variant["id"],
"name": variant["name"],
"note": variant.get("note", ""),
"available": bool(chosen),
"handler": chosen,
"unavailable": variant.get(
"unavailable",
"This HomeCore device driver is not available on this Green.",
),
}
)
item["variants"] = variants
item["available"] = any(x["available"] for x in variants)
result.append(item)
return result
def find_catalog_variant(brand_id, variant_id):
for brand in HOMECORE_DEVICE_CATALOG:
if brand["id"] != brand_id:
continue
variants = brand.get("variants", [])
variant = next(
(x for x in variants if x["id"] == variant_id),
variants[0] if len(variants) == 1 else None,
)
if not variant:
raise HTTPException(400, "Choose a HomeCore device type")
return brand, variant
raise HTTPException(404, "HomeCore device brand not found")
@router.get("/api/v1/device-catalog")
def device_catalog(request: Request):
ctx = auth_context(request)
require_owner(ctx)
try:
catalogue = public_catalog()
except Exception as exc:
raise HTTPException(
502,
"HomeCore could not read the device-driver catalogue from the controller: "
+ str(exc),
)
return {
"catalog": catalogue,
"expandable": True,
}
@router.post("/api/v1/device-setup/start")
async def device_setup_start(request: Request):
ctx = auth_context(request)
require_owner(ctx)
body = await request.json()
brand_id = str(body.get("brand_id") or "")
variant_id = str(body.get("variant_id") or "")
brand, variant = find_catalog_variant(brand_id, variant_id)
handlers = flow_handlers()
handler = next(
(
name
for name in variant.get("handlers", [])
if name in handlers
),
None,
)
if not handler:
raise HTTPException(
409,
variant.get(
"unavailable",
"This HomeCore device driver is not available on this Green.",
),
)
try:
step = ha_request(
"POST",
"/config/config_entries/flow",
payload={
"handler": handler,
"show_advanced_options": False,
},
timeout=60,
)
except httpx.HTTPStatusError as exc:
detail = ""
try:
detail = exc.response.json().get("message", "")
except Exception:
detail = exc.response.text
raise HTTPException(
exc.response.status_code,
detail or f"Could not start {brand['name']} setup",
)
activity(
"device.setup.started",
{
"brand": brand["name"],
"variant": variant["name"],
"handler": handler,
},
)
return {
"brand": brand["name"],
"variant": variant["name"],
"handler": handler,
"step": step,
}
@router.get("/api/v1/device-setup/flow/{flow_id}")
def device_setup_get(flow_id: str, request: Request):
ctx = auth_context(request)
require_owner(ctx)
try:
return ha_request(
"GET",
f"/config/config_entries/flow/{flow_id}",
timeout=60,
)
except httpx.HTTPStatusError as exc:
raise HTTPException(
exc.response.status_code,
"HomeCore device setup session is no longer available",
)
@router.post("/api/v1/device-setup/flow/{flow_id}")
async def device_setup_continue(flow_id: str, request: Request):
ctx = auth_context(request)
require_owner(ctx)
body = await request.json()
try:
step = ha_request(
"POST",
f"/config/config_entries/flow/{flow_id}",
payload=body,
timeout=120,
)
except httpx.HTTPStatusError as exc:
detail = ""
try:
detail = exc.response.json().get("message", "")
except Exception:
detail = exc.response.text
raise HTTPException(
exc.response.status_code,
detail or "HomeCore could not continue device setup",
)
if isinstance(step, dict) and step.get("type") == "create_entry":
activity(
"device.setup.completed",
{
"handler": step.get("handler", ""),
"title": step.get("title", ""),
},
)
return step
@router.delete("/api/v1/device-setup/flow/{flow_id}")
def device_setup_cancel(flow_id: str, request: Request):
ctx = auth_context(request)
require_owner(ctx)
try:
ha_request(
"DELETE",
f"/config/config_entries/flow/{flow_id}",
timeout=30,
)
except Exception:
pass
return {"ok": True}
@router.get("/api/v1/devices/{entity_id:path}")
def device(entity_id: str, request: Request):
ctx = auth_context(request)
return find_device(entity_id, ctx)
@router.post("/api/v1/devices/{entity_id:path}/command")
async def device_command(entity_id: str, request: Request):
ctx = auth_context(request)
item = find_device(entity_id, ctx)
if not can_control(ctx, item["category"]):
raise HTTPException(
403,
"Your HomeCore permissions do not allow this control",
)
body = await request.json()
command = str(body.pop("command", "")).strip()
body["entity_id"] = entity_id
service, data = safe_service(
item["domain"],
command,
body,
)
result = ha_request(
"POST",
f"/services/{item['domain']}/{service}",
payload=data,
)
actor = (
(ctx.get("user") or {}).get("email")
or (ctx.get("user") or {}).get("display_name")
or "local"
)
activity(
"device.command",
{
"entity_id": entity_id,
"name": item["name"],
"category": item["category"],
"command": command,
},
actor=actor,
)
return {"ok": True, "changed": result or []}
@router.post("/api/v1/rooms/{room_id}/command")
async def room_command(room_id: str, request: Request):
ctx = auth_context(request)
body = await request.json()
category = str(body.get("category") or "")
command = str(body.get("command") or "")
targets = [
x for x in get_devices(ctx)
if x["room_id"] == room_id and x["category"] == category
]
if not can_control(ctx, category):
raise HTTPException(
403,
"Your HomeCore permissions do not allow this control",
)
count = 0
for item in targets:
try:
service, data = safe_service(
item["domain"],
command,
{"entity_id": item["id"]},
)
ha_request(
"POST",
f"/services/{item['domain']}/{service}",
payload=data,
)
count += 1
except Exception:
pass
activity(
"room.command",
{
"room_id": room_id,
"category": category,
"command": command,
"count": count,
},
)
return {"ok": True, "count": count}
@router.get("/api/v1/history/{entity_id:path}")
def history(entity_id: str, request: Request, hours: int = 24):
ctx = auth_context(request)
find_device(entity_id, ctx)
hours = max(1, min(hours, 168))
start = datetime.now(timezone.utc) - timedelta(hours=hours)
raw = ha_request(
"GET",
"/history/period/" + quote(
start.isoformat(),
safe=":TZ+-",
),
params={
"filter_entity_id": entity_id,
"minimal_response": "",
"no_attributes": "",
"significant_changes_only": "",
},
timeout=30,
) or []
rows = (
raw[0]
if raw and isinstance(raw[0], list)
else []
)
return {
"entity_id": entity_id,
"hours": hours,
"points": [
{
"state": x.get("state"),
"last_changed": x.get("last_changed"),
}
for x in rows[-500:]
],
}
@router.get("/api/v1/activity")
def local_activity(request: Request):
auth_context(request)
return {
"activity": read_json(activity_path, [])[:200]
}
@router.get("/api/v1/camera/{entity_id:path}")
def camera(entity_id: str, request: Request):
ctx = auth_context(request)
item = find_device(entity_id, ctx)
if item["category"] != "cctv":
raise HTTPException(400, "Entity is not a camera")
if not can_view(ctx, "cctv"):
raise HTTPException(403, "Camera permission required")
response = ha_binary(
"/camera_proxy/" + quote(entity_id, safe="")
)
return Response(
content=response.content,
media_type=response.headers.get(
"content-type",
"image/jpeg",
),
headers={"Cache-Control": "no-store"},
)
def require_owner(ctx):
if not ctx.get("owner") and ctx.get("mode") != "ingress":
raise HTTPException(403, "Owner permission required")
@router.post("/api/v1/settings")
async def settings(request: Request):
ctx = auth_context(request)
require_owner(ctx)
body = await request.json()
product = load_product()
if "home_name" in body:
name = str(body.get("home_name") or "").strip()
if len(name) > 80:
raise HTTPException(400, "Home name is too long")
product["home_name"] = name
if body.get("theme") in ("system", "dark", "light"):
product["theme"] = body["theme"]
save_product(product)
activity(
"settings.updated",
{"home_name": product.get("home_name")},
)
return {
"ok": True,
"home_name": product.get("home_name"),
"theme": product.get("theme"),
}
@router.post("/api/v1/rooms")
async def create_room(request: Request):
ctx = auth_context(request)
require_owner(ctx)
body = await request.json()
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(400, "Room name is required")
if len(name) > 60:
raise HTTPException(400, "Room name is too long")
product = load_product()
room = {
"id": "room_" + uuid.uuid4().hex[:12],
"name": name,
"order": len(product["rooms"]),
}
product["rooms"].append(room)
save_product(product)
activity(
"room.created",
{"room_id": room["id"], "name": room["name"]},
)
return room
@router.put("/api/v1/rooms/{room_id}")
async def update_room(room_id: str, request: Request):
ctx = auth_context(request)
require_owner(ctx)
body = await request.json()
product = load_product()
room = next(
(
x
for x in product["rooms"]
if x.get("id") == room_id
),
None,
)
if not room:
raise HTTPException(404, "Room not found")
if "name" in body:
name = str(body.get("name") or "").strip()
if not name:
raise HTTPException(
400,
"Room name is required",
)
room["name"] = name[:60]
if "order" in body:
room["order"] = int(body["order"])
save_product(product)
activity(
"room.updated",
{"room_id": room_id, "name": room["name"]},
)
return room
@router.delete("/api/v1/rooms/{room_id}")
def delete_room(room_id: str, request: Request):
ctx = auth_context(request)
require_owner(ctx)
product = load_product()
before = len(product["rooms"])
product["rooms"] = [
x
for x in product["rooms"]
if x.get("id") != room_id
]
if len(product["rooms"]) == before:
raise HTTPException(404, "Room not found")
for settings in product["devices"].values():
if settings.get("room_id") == room_id:
settings["room_id"] = ""
save_product(product)
activity("room.deleted", {"room_id": room_id})
return {"ok": True}
@router.post("/api/v1/device-settings")
async def device_settings(request: Request):
ctx = auth_context(request)
require_owner(ctx)
body = await request.json()
entity_id = str(
body.get("entity_id") or ""
).strip()
if not entity_id or "." not in entity_id:
raise HTTPException(
400,
"Valid entity_id is required",
)
product = load_product()
value = dict(
product["devices"].get(entity_id) or {}
)
if "room_id" in body:
room_id = str(body.get("room_id") or "")
if room_id and not any(
x.get("id") == room_id
for x in product["rooms"]
):
raise HTTPException(
400,
"Room does not exist",
)
value["room_id"] = room_id
if "name" in body:
value["name"] = str(
body.get("name") or ""
).strip()[:80]
if "favorite" in body:
value["favorite"] = bool(
body["favorite"]
)
if "added" in body:
value["added"] = bool(
body["added"]
)
if "hidden" in body:
value["hidden"] = bool(
body["hidden"]
)
product["devices"][entity_id] = value
save_product(product)
activity(
"device.settings",
{"entity_id": entity_id, **value},
)
return {
"ok": True,
"entity_id": entity_id,
"settings": value,
}
@router.get("/api/v1/cloud/status")
def cloud_status(request: Request):
auth_context(request)
api = options()["cloud_api"].rstrip("/")
try:
response = httpx.get(api + "/health", timeout=5)
return {
"connected": response.status_code < 500,
"status": "connected" if response.status_code < 500 else "offline",
}
except Exception:
return {"connected": False, "status": "offline"}
@router.get("/api/v1/cloud/access")
def cloud_access(request: Request):
ctx = auth_context(request)
headers = cloud_headers(ctx)
api = options()["cloud_api"].rstrip("/")
property_id = load_state().get("property_id")
with httpx.Client(timeout=20) as client:
props = client.get(
api + "/api/v1/properties",
headers=headers,
)
props.raise_for_status()
prop = next(
(
x
for x in props.json()
if x.get("id") == property_id
),
None,
)
if not prop:
raise HTTPException(
403,
"Account no longer has access to this home",
)
members = []
if prop.get("owner"):
result = client.get(
f"{api}/api/v1/properties/{property_id}/members",
headers=headers,
)
if result.status_code < 400:
members = result.json()
user = ctx.get("user") or {}
return {
"owner": bool(prop.get("owner")),
"email": user.get("email", ""),
"permissions": prop.get("permissions") or [],
"members": members,
}
@router.get("/api/v1/cloud/permission-options")
def cloud_permission_options(request: Request):
ctx = auth_context(request)
headers = cloud_headers(ctx)
api = options()["cloud_api"].rstrip("/")
response = httpx.get(
api + "/api/v1/permissions",
headers=headers,
timeout=15,
)
response.raise_for_status()
return response.json()
@router.post("/api/v1/cloud/share")
async def cloud_share(request: Request):
ctx = auth_context(request)
headers = cloud_headers(ctx)
body = await request.json()
api = options()["cloud_api"].rstrip("/")
property_id = load_state().get("property_id")
response = httpx.post(
f"{api}/api/v1/properties/{property_id}/share",
headers=headers,
json={
"email": str(
body.get("email") or ""
).strip().lower(),
"permissions": list(
body.get("permissions") or []
),
},
timeout=20,
)
if response.status_code >= 400:
try:
detail = response.json().get("detail")
except Exception:
detail = response.text
raise HTTPException(
response.status_code,
detail or "HomeCore sharing failed",
)
activity(
"sharing.updated",
{
"email": body.get("email"),
"permissions": body.get("permissions"),
},
)
return response.json()
@router.delete("/api/v1/cloud/members/{user_id}")
def cloud_revoke(user_id: str, request: Request):
ctx = auth_context(request)
headers = cloud_headers(ctx)
api = options()["cloud_api"].rstrip("/")
property_id = load_state().get("property_id")
response = httpx.delete(
f"{api}/api/v1/properties/{property_id}/members/{user_id}",
headers=headers,
timeout=20,
)
if response.status_code >= 400:
try:
detail = response.json().get("detail")
except Exception:
detail = response.text
raise HTTPException(
response.status_code,
detail or "HomeCore access removal failed",
)
activity(
"sharing.revoked",
{"user_id": user_id},
)
return response.json()
return router
# ===== End HomeCore 1.0 product layer =====
app = FastAPI(
title="HomeCore Controller",
version=VERSION,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://homecore.home-core.co.uk",
"https://www.home-core.co.uk",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
app.include_router(
build_router(
version=VERSION,
data_dir=DATA,
load_state=load_state,
options=options,
ha_version=ha_version,
)
)
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 HTMLResponse(APP_HTML)
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" action="">'
'<input type="hidden" name="stage" value="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("/", 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,
"properties": {p["id"]: p 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" action="">'
'<input type="hidden" name="stage" value="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["properties"]:
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)
)
prop = pending["properties"][property_id]
out["property_name"] = prop.get("name", "")
PENDING.pop(nonce, None)
save_state(out)
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>'
)
@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,
)