diff --git a/homecore_controller/CHANGELOG.md b/homecore_controller/CHANGELOG.md index d882b1d..f652a20 100644 --- a/homecore_controller/CHANGELOG.md +++ b/homecore_controller/CHANGELOG.md @@ -1,5 +1,13 @@ # HomeCore Controller Changelog +## 1.0.1 + +- Repackages the complete HomeCore 1.0 product as a repository-safe update from 0.1.18. +- Keeps all HomeCore 1.0 functionality from the attempted 1.0.0 release. +- Folds the customer UI and HomeCore platform layer into the existing controller `main.py` so the Admin/Gitea patch does not need to create new source files. +- Preserves the separate HomeCore Updater and manual per-controller deployment architecture. +- Automatic rollout remains disabled. + ## 0.1.18 - First end-to-end validation release for the separate HomeCore Updater architecture. diff --git a/homecore_controller/app/main.py b/homecore_controller/app/main.py index 313dbfe..13f11c6 100644 --- a/homecore_controller/app/main.py +++ b/homecore_controller/app/main.py @@ -10,13 +10,14 @@ 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 = "0.1.18" +VERSION = "1.0.1" print( "HOMECORE SUPERVISOR: token " @@ -160,11 +161,1166 @@ def heartbeat_loop(): 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 = '\n\n\n\n\nHomeCore\n\n\n\n
Loading home…
\n
\n
HomeCore

Settings

\n
\n
◉
HomeCore Intercom
Someone is at the door
\n' + + +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 = '''