1681 lines
78 KiB
Python
1681 lines
78 KiB
Python
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.1"
|
||
|
||
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",
|
||
}
|
||
|
||
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",
|
||
"other": "Other",
|
||
}
|
||
|
||
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:26px}.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(--green);margin-right:7px}\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}.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:26px;font-weight:850;margin-top:14px}.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.room-empty{padding:50px 20px;text-align:center;border:1px dashed var(--line);border-radius:22px;color:var(--muted)}.bottom-nav{position:fixed;left:50%;transform:translateX(-50%);bottom:18px;width:min(760px,calc(100% - 28px));display:grid;grid-template-columns:1fr 1.15fr 1fr;gap:9px;z-index:20}.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(760px,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-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{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}.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.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 112px}.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}}\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"><span class="status-dot"></span>Local</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:[],current:0,screens:[],lastDoorbell:{},poll:null};\nconst BASE=location.pathname.endsWith(\'/\')?location.pathname:location.pathname+\'/\';\nconst q=s=>document.querySelector(s);const esc=s=>String(s??\'\').replace(/[&<>"\']/g,m=>({\'&\':\'&\',\'<\':\'<\',\'>\':\'>\',\'"\':\'"\',"\'":\''\'}[m]));\nasync function api(path,opt={}){const r=await fetch(BASE+path.replace(/^\\/+/,\'\'),{...opt,headers:{...(opt.headers||{}),\'Content-Type\':opt.body?\'application/json\':(opt.headers||{})[\'Content-Type\']||\'application/json\'},body:opt.body&&typeof opt.body!==\'string\'?JSON.stringify(opt.body):opt.body});if(r.status===401){showLogin();throw new Error(\'Sign in required\')}if(!r.ok){let t=await r.text();try{t=JSON.parse(t).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(m,ok=true){const e=q(\'#toast\');e.textContent=m;e.style.borderColor=ok?\'#55d68b88\':\'#ff6d7788\';e.classList.add(\'show\');setTimeout(()=>e.classList.remove(\'show\'),3000)}\nfunction icon(c){return({lighting:\'✦\',heating:\'◉\',cctv:\'◫\',blinds:\'▤\',alarm:\'⌾\',doorbell:\'◌\',entertainment:\'▶\',security:\'◆\',switch:\'●\',fan:\'◍\',scene:\'✧\',automation:\'◇\'})[c]||\'•\'}\nfunction roomName(id){const r=HC.rooms.find(x=>x.id===id);return r?r.name:\'Unassigned\'}function title(s){return s.type===\'overview\'?\'Overview\':s.room.name}\nfunction nav(){if(!HC.screens.length)return;const n=HC.screens.length,i=HC.current,idx=[(i-1+n)%n,i,(i+1)%n];q(\'#bottomNav\').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 go(i){HC.current=i;render();nav();scrollTo({top:0,behavior:\'smooth\'})}\nfunction stat(k,v,s=\'\'){return`<div class="stat"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div>${s?`<div class="muted" style="font-size:11px;margin-top:6px">${esc(s)}</div>`:\'\'}</div>`}\nfunction b(label,id,cmd,extra={}){return`<button onclick=\'cmd(${JSON.stringify(id)},${JSON.stringify(cmd)},${JSON.stringify(extra)})\'>${esc(label)}</button>`}\nfunction device(d){let c=\'\';if(d.category===\'lighting\'){c+=d.state===\'on\'?b(\'Off\',d.id,\'turn_off\'):b(\'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)})">`;if(d.capabilities.includes(\'color\'))c+=`<label class="muted" style="font-size:11px">Colour <input type="color" value="#ffffff" onchange="cmd(\'${esc(d.id)}\',\'turn_on\',{rgb_color:hexRgb(this.value)})"></label>`;if(d.capabilities.includes(\'color_temperature\'))c+=`<label class="muted" style="width:100%;font-size:11px">Colour temperature<input class="range" type="range" min="2000" max="6500" value="${d.attributes.color_temp_kelvin||4000}" onchange="cmd(\'${esc(d.id)}\',\'turn_on\',{color_temp_kelvin:Number(this.value)})"></label>`}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+=b(\'−\',d.id,\'set_temperature\',{temperature:Number(target)-.5})+b(\'+\',d.id,\'set_temperature\',{temperature:Number(target)+.5});if((d.attributes.hvac_modes||[]).length)c+=`<select onchange="cmd(\'${esc(d.id)}\',\'set_mode\',{hvac_mode:this.value})" style="background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:7px">${d.attributes.hvac_modes.map(x=>`<option value="${esc(x)}" ${d.state===x?\'selected\':\'\'}>${esc(x)}</option>`).join(\'\')}</select>`}else if(d.category===\'blinds\'){c+=b(\'Open\',d.id,\'open\')+b(\'Stop\',d.id,\'stop\')+b(\'Close\',d.id,\'close\');if(d.capabilities.includes(\'position\'))c+=`<input class="range" type="range" min="0" max="100" value="${d.attributes.current_position||0}" onchange="cmd(\'${esc(d.id)}\',\'set_position\',{position:Number(this.value)})">`}else if(d.category===\'alarm\'){c+=`<button onclick="secureCmd(\'${esc(d.id)}\',\'arm_home\')">Home</button><button onclick="secureCmd(\'${esc(d.id)}\',\'arm_away\')">Away</button><button onclick="secureCmd(\'${esc(d.id)}\',\'arm_night\')">Night</button><button onclick="secureCmd(\'${esc(d.id)}\',\'disarm\')">Disarm</button>`}else if(d.category===\'entertainment\'){if(d.attributes.media_title)c+=`<div style="width:100%;font-weight:700">${esc(d.attributes.media_title)}</div>`;c+=b(\'Power\',d.id,d.state===\'off\'?\'turn_on\':\'turn_off\')+b(\'Play / pause\',d.id,\'play_pause\');if(d.capabilities.includes(\'volume\'))c+=`<input class="range" type="range" min="0" max="100" value="${Math.round((d.attributes.volume_level||0)*100)}" onchange="cmd(\'${esc(d.id)}\',\'volume\',{volume_level:Number(this.value)/100})">`;if((d.attributes.source_list||[]).length)c+=`<select onchange="cmd(\'${esc(d.id)}\',\'source\',{source:this.value})" style="background:var(--panel2);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:7px">${d.attributes.source_list.map(x=>`<option value="${esc(x)}" ${d.attributes.source===x?\'selected\':\'\'}>${esc(x)}</option>`).join(\'\')}</select>`}else if(d.category===\'security\'){c+=d.state===\'locked\'?`<button onclick="secureCmd(\'${esc(d.id)}\',\'unlock\')">Unlock</button>`:b(\'Lock\',d.id,\'lock\')}else if(d.category===\'switch\'||d.category===\'fan\'){c+=b(d.state===\'on\'?\'Off\':\'On\',d.id,d.state===\'on\'?\'turn_off\':\'turn_on\')}else if(d.category===\'scene\'){c+=b(\'Activate\',d.id,\'activate\')}else if(d.category===\'automation\'){c+=b(\'Run\',d.id,\'trigger\')}else if(d.category===\'cctv\'){c+=`<img class="camera" loading="lazy" src="${BASE}${d.camera_url.replace(/^\\//,\'\')}?t=${Date.now()}" onclick="cameraModal(\'${esc(d.id)}\',\'${esc(d.name)}\')">`}else if(d.category===\'doorbell\'){c+=`<button onclick="openDeviceRoom(\'${esc(d.id)}\')">Open intercom</button>`}const state=d.available?String(d.state||\'\'):\'Offline\';c+=`<button onclick="showHistory(\'${esc(d.id)}\',\'${esc(d.name).replaceAll("\'","'")}\')">History</button>`;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">${icon(d.category)} ${esc(d.name)}</div><div class="device-sub">${esc(roomName(d.room_id))}</div></div><div class="state ${d.state===\'on\'?\'on\':\'\'}">${esc(state)}</div></div><div class="controls">${c}</div></div>`}\nfunction section(name,items){return items.length?`<section class="section"><div class="eyebrow" style="margin-bottom:10px">${esc(name)}</div><div class="grid cards">${items.map(device).join(\'\')}</div></section>`:\'\'}\nfunction overview(){const o=HC.home.overview,d=HC.devices,f=d.filter(x=>x.favorite),sec=d.filter(x=>[\'alarm\',\'security\'].includes(x.category)),sc=d.filter(x=>x.category===\'scene\'),a=(HC.home.activity||[]).slice(0,6);return`<div class="page-head"><div><div class="eyebrow">HomeCore</div><h1>Overview</h1><div class="muted">${esc(HC.home.home_name)}</div></div></div><div class="grid summary">${stat(\'Lighting\',`${o.lights_on} / ${o.lights_total}`,\'on\')}${stat(\'Temperature\',o.average_temperature==null?\'—\':o.average_temperature+\'°C\',\'average\')}${stat(\'Media\',o.media_active,\'active\')}${stat(\'Cameras\',o.cameras,\'available\')}${stat(\'Alarm\',o.alarm_state||\'—\')}${stat(\'Unlocked\',o.unlocked)}</div>${section(\'Favorites\',f)}${section(\'Scenes\',sc)}${section(\'Security\',sec)}<section class="section"><h2>Recent activity</h2><div class="card">${a.length?a.map(x=>`<div class="activity-row"><div><strong>${esc(x.type.replaceAll(\'.\',\' \'))}</strong><div class="muted" style="font-size:11px">${esc(new Date(x.created_at).toLocaleString())}</div></div><div class="muted">${esc(x.detail?.name||x.detail?.entity_id||\'\')}</div></div>`).join(\'\'):\'<div class="muted">HomeCore activity will appear here.</div>\'}</div></section>`}\nfunction room(r){const items=HC.devices.filter(x=>x.room_id===r.id),cats=[...new Set(items.map(x=>x.category))];return`<div class="page-head"><div><div class="eyebrow">${esc(r.icon||\'⌂\')} Room</div><h1>${esc(r.name)}</h1><div class="muted">${items.length} connected ${items.length===1?\'device\':\'devices\'}</div></div><button class="btn" onclick="roomLights(\'${r.id}\',\'turn_off\')">All lights off</button></div>${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="room-empty"><h2>No devices assigned</h2><div>Open Settings to assign Home Assistant devices to this room.</div></div>\'}`}\nfunction render(){if(!HC.home)return;const s=HC.screens[HC.current]||HC.screens[0];q(\'#content\').innerHTML=s.type===\'overview\'?overview():room(s.room)}\nasync function refresh(){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;render();nav();watchDoorbells()}catch(e){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)}}\nasync function secureCmd(id,command){const code=prompt(\'Enter the security code if this device requires one. Leave blank if not required.\',\'\');if(code===null)return;return cmd(id,command,code?{code}:{});}\nfunction hexRgb(v){return [parseInt(v.slice(1,3),16),parseInt(v.slice(3,5),16),parseInt(v.slice(5,7),16)]}\nasync function showHistory(id,name){try{const d=await api(`api/v1/history/${encodeURIComponent(id)}?hours=24`);const p=(d.points||[]).slice(-40).reverse();q(\'#modal\').innerHTML=`<div class="drawer-head"><div><h2>${esc(name)}</h2><div class="muted">Last 24 hours</div></div><button class="icon-btn" onclick="closeModal()">✕</button></div>${p.length?p.map(x=>`<div class="activity-row"><strong>${esc(x.state)}</strong><span class="muted">${esc(x.last_changed?new Date(x.last_changed).toLocaleString():\'\')}</span></div>`).join(\'\'):\'<div class="muted">No history available.</div>\'}`;q(\'#modalWrap\').classList.add(\'open\')}catch(e){toast(e.message,false)}}\nasync function roomLights(id,command){try{await api(`api/v1/rooms/${encodeURIComponent(id)}/command`,{method:\'POST\',body:{category:\'lighting\',command}});toast(\'Room updated\');setTimeout(refresh,450)}catch(e){toast(e.message,false)}}\nfunction cameraModal(id,name){q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>${esc(name)}</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><img class="camera" style="margin:0" src="${BASE}api/v1/camera/${encodeURIComponent(id)}?t=${Date.now()}"><p class="muted">Camera viewing is supplied by Home Assistant. Streaming capability depends on the camera integration.</p>`;q(\'#modalWrap\').classList.add(\'open\')}function closeModal(){q(\'#modalWrap\').classList.remove(\'open\')}\nfunction openDeviceRoom(id){const d=HC.devices.find(x=>x.id===id);const i=d?HC.screens.findIndex(x=>x.type===\'room\'&&x.room.id===d.room_id):-1;if(i>=0)go(i)}\nfunction watchDoorbells(){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())){HC.lastDoorbell[d.id]=d.last_changed;q(\'#ringTitle\').textContent=d.name;q(\'#ringRoom\').textContent=roomName(d.room_id);q(\'#ring\').dataset.device=d.id;q(\'#ring\').classList.add(\'show\')}else HC.lastDoorbell[d.id]=d.last_changed}}function dismissRing(){q(\'#ring\').classList.remove(\'show\')}function viewDoorbell(){const id=q(\'#ring\').dataset.device;dismissRing();openDeviceRoom(id)}\nfunction openSettings(){q(\'#settingsOverlay\').classList.add(\'open\');settings()}function closeSettings(){q(\'#settingsOverlay\').classList.remove(\'open\')}\nfunction settings(){q(\'#settingsContent\').innerHTML=`<div class="settings-grid"><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="theme"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></div><button class="btn primary" onclick="saveSettings()">Save</button></div><div class="settings-card"><h2>Controller</h2><div>HomeCore ${esc(HC.home.version)}</div><div class="muted" style="margin-top:8px">Home Assistant ${esc(HC.home.ha_version||\'—\')}</div><div class="muted" style="margin-top:8px">${HC.devices.length} devices · ${HC.rooms.length} rooms</div></div></div><section class="section"><div class="page-head"><div><h2>Rooms</h2><div class="muted">Create any room you need, including Garden.</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.icon||\'⌂\')} ${esc(r.name)}</strong></div><div class="row-actions"><button class="btn mini" onclick="editRoom(\'${r.id}\')">Edit</button><button class="btn danger mini" onclick="delRoom(\'${r.id}\')">Delete</button></div></div>`).join(\'\'):\'<div class="muted">No rooms yet.</div>\'}</div></section><section class="section"><h2>Devices</h2><div class="settings-card">${HC.devices.map(d=>`<div class="device-row"><div><strong>${esc(d.name)}</strong><div class="muted" style="font-size:11px">${esc(d.entity_id)} · ${esc(d.category_label)}</div></div><div><select onchange="assign(\'${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="favorite(\'${esc(d.id)}\',${!d.favorite})">${d.favorite?\'★\':\'☆\'}</button><button class="btn mini" onclick="renameDev(\'${esc(d.id)}\')">Rename</button></div></div>`).join(\'\')}</div></section><section class="section"><h2>People & permissions</h2><div id="people" class="settings-card"><div class="muted">Loading access…</div></div></section>`;q(\'#theme\').value=HC.home.theme||\'system\';loadPeople()}\nfunction applyTheme(t){document.documentElement.dataset.theme=t===\'system\'?(matchMedia(\'(prefers-color-scheme: light)\').matches?\'light\':\'dark\'):t}\nasync function saveSettings(){try{const r=await api(\'api/v1/settings\',{method:\'POST\',body:{home_name:q(\'#homeName\').value,theme:q(\'#theme\').value}});applyTheme(r.theme);toast(\'Settings saved\');await refresh();settings()}catch(e){toast(e.message,false)}}\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>Name</label><input id="roomName"></div><div class="field"><label>Icon</label><input id="roomIcon" value="⌂"></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,icon:q(\'#roomIcon\').value}});closeModal();await refresh();settings();toast(\'Room created\')}catch(e){toast(e.message,false)}}\nfunction editRoom(id){const r=HC.rooms.find(x=>x.id===id);q(\'#modal\').innerHTML=`<div class="drawer-head"><h2>Edit room</h2><button class="icon-btn" onclick="closeModal()">✕</button></div><div class="field"><label>Name</label><input id="roomName" value="${esc(r.name)}"></div><div class="field"><label>Icon</label><input id="roomIcon" value="${esc(r.icon||\'⌂\')}"></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,icon:q(\'#roomIcon\').value}});closeModal();await refresh();settings();toast(\'Room saved\')}catch(e){toast(e.message,false)}}async function delRoom(id){if(!confirm(\'Delete this room? Devices become unassigned.\'))return;try{await api(`api/v1/rooms/${id}`,{method:\'DELETE\'});await refresh();settings()}catch(e){toast(e.message,false)}}\nasync function assign(id,room){try{await api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,room_id:room}});await refresh();settings()}catch(e){toast(e.message,false)}}async function favorite(id,v){try{await api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,favorite:v}});await refresh();settings()}catch(e){toast(e.message,false)}}async function renameDev(id){const d=HC.devices.find(x=>x.id===id),v=prompt(\'HomeCore device name\',d?.name||\'\');if(v!==null)try{await api(\'api/v1/device-settings\',{method:\'POST\',body:{entity_id:id,name:v}});await refresh();settings()}catch(e){toast(e.message,false)}}\nasync function loadPeople(){const e=q(\'#people\');if(!e)return;try{const d=await api(\'api/v1/cloud/access\');e.innerHTML=`<div class="page-head" style="margin-top:0"><div><strong>${d.owner?\'Owner account\':\'HomeCore account\'}</strong><div class="muted" style="font-size:12px">${esc(d.email||\'\')}</div></div>${d.owner?\'<button class="btn primary" onclick="share()">Share access</button>\':\'\'}</div>${(d.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>${d.owner?`<button class="btn danger mini" onclick="revoke(\'${m.user_id}\')">Remove</button>`:\'\'}</div>`).join(\'\')||\'<div class="muted">No shared users.</div>\'}`}catch(err){e.innerHTML=`<div class="muted">Connect the Owner HomeCore account to manage sharing and granular 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="email" type="email"></div><div class="field"><label>Password</label><input id="password" 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(\'#email\').value,password:q(\'#password\').value}});closeModal();toast(\'HomeCore account connected\');loadPeople()}catch(e){toast(e.message,false)}}\nasync function share(){try{const d=await api(\'api/v1/cloud/permission-options\');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>${(d.permissions||[]).map(p=>`<label style="display:block;margin:7px 0"><input type="checkbox" class="perm" value="${esc(p)}"> ${esc(p.replaceAll(\'_\',\' \'))}</label>`).join(\'\')}</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{await api(\'api/v1/cloud/share\',{method:\'POST\',body:{email:q(\'#shareEmail\').value,permissions:[...document.querySelectorAll(\'.perm:checked\')].map(x=>x.value)}});closeModal();toast(\'Access updated\');loadPeople()}catch(e){toast(e.message,false)}}async function revoke(id){if(!confirm(\'Remove this person from the home?\'))return;try{await api(`api/v1/cloud/members/${encodeURIComponent(id)}`,{method:\'DELETE\'});loadPeople()}catch(e){toast(e.message,false)}}\nfunction 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>`}\nasync 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\');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></body></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 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):
|
||
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)
|
||
if category == "other" and domain in ("binary_sensor", "event"):
|
||
return None
|
||
|
||
override = (product.get("devices") or {}).get(entity_id, {})
|
||
if 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")),
|
||
"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 get_devices(ctx=None):
|
||
product = load_product()
|
||
states = ha_request("GET", "/states") or []
|
||
out = []
|
||
for raw in states:
|
||
item = normalize(raw, product)
|
||
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 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/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,
|
||
"icon": str(body.get("icon") or "⌂")[:8],
|
||
"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 "icon" in body:
|
||
room["icon"] = str(
|
||
body.get("icon") or "⌂"
|
||
)[:8]
|
||
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 "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/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,
|
||
)
|