Dashboard Studio 1.4.0: drag-and-drop dashboards for Home Assistant
A Home Assistant app with its own sidebar tab: free-form grid editor, live entity widgets (lights, climate, car, calendar, Immich photos, Afvalwijzer waste collection and more), themes, tabs with a side menu, kiosk mode and English/Dutch translations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""Read-only Immich access for the Photos widget.
|
||||
|
||||
The API key stays on the server: the browser only ever sees album names,
|
||||
asset ids and image bytes proxied through these routes.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
LOG = logging.getLogger("dashboard_studio.immich")
|
||||
ID_RE = re.compile(r"^[0-9a-fA-F-]{8,64}$")
|
||||
CACHE_SECONDS = 600
|
||||
NOT_CONFIGURED = "Immich isn’t set up. Add its URL and an API key on the app’s Configuration tab."
|
||||
|
||||
|
||||
class Immich:
|
||||
def __init__(self, url, api_key, mock=False):
|
||||
self.url = (url or "").rstrip("/")
|
||||
self.key = api_key or ""
|
||||
self.mock = mock and not (self.url and self.key)
|
||||
self.session = None
|
||||
self._assets = {} # album id -> (fetched_at, [assets])
|
||||
|
||||
@property
|
||||
def configured(self):
|
||||
return self.mock or bool(self.url and self.key)
|
||||
|
||||
async def start(self):
|
||||
self.session = aiohttp.ClientSession(
|
||||
headers={"x-api-key": self.key, "Accept": "application/json"},
|
||||
timeout=aiohttp.ClientTimeout(total=20),
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def _json(self, method, path, **kwargs):
|
||||
async with self.session.request(method, f"{self.url}/api{path}", **kwargs) as resp:
|
||||
if resp.status in (401, 403):
|
||||
raise web.HTTPBadGateway(text="Immich rejected the API key. Check it on the app’s Configuration tab.")
|
||||
if resp.status >= 400:
|
||||
raise web.HTTPBadGateway(text=f"Immich returned HTTP {resp.status} for {path}.")
|
||||
return await resp.json()
|
||||
|
||||
# ------------------------------------------------------------ data
|
||||
|
||||
async def albums(self):
|
||||
if self.mock:
|
||||
return [
|
||||
{"id": "11111111-aaaa-4bbb-8ccc-000000000001", "name": "Summer holiday", "count": 6},
|
||||
{"id": "11111111-aaaa-4bbb-8ccc-000000000002", "name": "Family", "count": 4},
|
||||
{"id": "11111111-aaaa-4bbb-8ccc-000000000003", "name": "Garden", "count": 3},
|
||||
]
|
||||
rows = await self._json("GET", "/albums")
|
||||
shared = await self._json("GET", "/albums", params={"shared": "true"})
|
||||
seen, out = set(), []
|
||||
for a in rows + shared:
|
||||
if a["id"] in seen:
|
||||
continue
|
||||
seen.add(a["id"])
|
||||
out.append({"id": a["id"], "name": a.get("albumName") or "Untitled", "count": a.get("assetCount", 0)})
|
||||
return sorted(out, key=lambda a: a["name"].lower())
|
||||
|
||||
async def album_assets(self, album_id):
|
||||
cached = self._assets.get(album_id)
|
||||
if cached and time.time() - cached[0] < CACHE_SECONDS:
|
||||
return cached[1]
|
||||
if self.mock:
|
||||
n = int(album_id[-1]) + 2
|
||||
assets = [{"id": f"{album_id[:-4]}{i:04d}", "date": f"2026-0{(i % 9) + 1}-1{i % 9}T12:00:00Z"} for i in range(n * 2)]
|
||||
else:
|
||||
assets = await self._fetch_album_assets(album_id)
|
||||
self._assets[album_id] = (time.time(), assets)
|
||||
return assets
|
||||
|
||||
async def _fetch_album_assets(self, album_id):
|
||||
album = await self._json("GET", f"/albums/{album_id}")
|
||||
items = album.get("assets")
|
||||
if not items and album.get("assetCount"):
|
||||
# Newer Immich versions leave assets out of the album response; search instead.
|
||||
items, page = [], 1
|
||||
while page and len(items) < 5000:
|
||||
res = await self._json("POST", "/search/metadata", json={"albumIds": [album_id], "type": "IMAGE", "size": 1000, "page": page})
|
||||
block = res.get("assets", {})
|
||||
items += block.get("items", [])
|
||||
page = block.get("nextPage")
|
||||
page = int(page) if page else None
|
||||
return [
|
||||
{"id": a["id"], "date": a.get("localDateTime") or a.get("fileCreatedAt")}
|
||||
for a in items or []
|
||||
if a.get("type", "IMAGE") == "IMAGE" and not a.get("isTrashed")
|
||||
]
|
||||
|
||||
async def preview(self, asset_id):
|
||||
if self.mock:
|
||||
return self._mock_image(asset_id), "image/svg+xml"
|
||||
async with self.session.get(f"{self.url}/api/assets/{asset_id}/thumbnail", params={"size": "preview"}) as resp:
|
||||
if resp.status >= 400:
|
||||
raise web.HTTPBadGateway(text=f"Immich returned HTTP {resp.status} for that photo.")
|
||||
return await resp.read(), resp.content_type or "image/jpeg"
|
||||
|
||||
@staticmethod
|
||||
def _mock_image(asset_id):
|
||||
h = hashlib.md5(asset_id.encode()).hexdigest()
|
||||
c1, c2 = f"#{h[:6]}", f"#{h[6:12]}"
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs>
|
||||
<rect width="1200" height="800" fill="url(#g)"/><circle cx="{300 + int(h[12:14], 16) * 3}" cy="260" r="110" fill="#fff" opacity=".35"/>
|
||||
<path d="M0 640 L300 420 L520 600 L760 360 L1200 700 L1200 800 L0 800Z" fill="#000" opacity=".25"/>
|
||||
<text x="40" y="760" fill="#fff" font-family="sans-serif" font-size="32" opacity=".8">Mock photo {asset_id[-4:]}</text></svg>""".encode()
|
||||
|
||||
# ------------------------------------------------------------ routes
|
||||
|
||||
def routes(self):
|
||||
def guard():
|
||||
if not self.configured:
|
||||
raise web.HTTPServiceUnavailable(text=NOT_CONFIGURED)
|
||||
|
||||
async def albums(request):
|
||||
guard()
|
||||
try:
|
||||
return web.json_response(await self.albums())
|
||||
except aiohttp.ClientError as err:
|
||||
LOG.error("Immich albums: %s", err)
|
||||
raise web.HTTPBadGateway(text="Couldn’t reach Immich. Check the URL on the app’s Configuration tab.")
|
||||
|
||||
async def assets(request):
|
||||
guard()
|
||||
album_id = request.match_info["album_id"]
|
||||
if not ID_RE.match(album_id):
|
||||
raise web.HTTPBadRequest()
|
||||
try:
|
||||
return web.json_response(await self.album_assets(album_id))
|
||||
except aiohttp.ClientError as err:
|
||||
LOG.error("Immich album %s: %s", album_id, err)
|
||||
raise web.HTTPBadGateway(text="Couldn’t reach Immich.")
|
||||
|
||||
async def preview(request):
|
||||
guard()
|
||||
asset_id = request.match_info["asset_id"]
|
||||
if not ID_RE.match(asset_id):
|
||||
raise web.HTTPBadRequest()
|
||||
try:
|
||||
body, ctype = await self.preview(asset_id)
|
||||
except aiohttp.ClientError:
|
||||
raise web.HTTPBadGateway(text="Couldn’t reach Immich.")
|
||||
return web.Response(body=body, content_type=ctype, headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
return [
|
||||
web.get("/api/immich/albums", albums),
|
||||
web.get("/api/immich/albums/{album_id}/assets", assets),
|
||||
web.get("/api/immich/assets/{asset_id}/preview", preview),
|
||||
]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Dashboard Studio backend.
|
||||
|
||||
Serves the editor UI over Home Assistant ingress, stores dashboards in /data,
|
||||
and proxies the Home Assistant websocket API using the Supervisor token so the
|
||||
browser never needs its own long-lived access token.
|
||||
|
||||
Without a SUPERVISOR_TOKEN (local development) it runs against a mock Home
|
||||
Assistant with a handful of fake entities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
from immich import Immich
|
||||
from mock_ha import MockHA
|
||||
|
||||
LOG = logging.getLogger("dashboard_studio")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
SUPERVISOR_TOKEN = os.environ.get("SUPERVISOR_TOKEN")
|
||||
# Inside Home Assistant the Supervisor always writes /data/options.json. Fake data is
|
||||
# only for local development, never a silent fallback on a real install.
|
||||
IN_HOME_ASSISTANT = Path("/data/options.json").exists()
|
||||
MOCK = not SUPERVISOR_TOKEN and not IN_HOME_ASSISTANT
|
||||
HA_WS_URL = "ws://supervisor/core/websocket"
|
||||
HA_API_URL = "http://supervisor/core/api"
|
||||
INGRESS_PROXY_IP = "172.30.32.2"
|
||||
|
||||
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
|
||||
DATA_DIR = Path("/data") if Path("/data").is_dir() else Path(__file__).resolve().parent.parent / "data"
|
||||
STORE_FILE = DATA_DIR / "dashboards.json"
|
||||
PORT = int(os.environ.get("PORT", "8099"))
|
||||
|
||||
|
||||
def load_options():
|
||||
"""App options from the Configuration tab (/data/options.json), or env vars when developing."""
|
||||
opts = {}
|
||||
path = Path("/data/options.json")
|
||||
if path.exists():
|
||||
try:
|
||||
opts = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
LOG.exception("Could not read %s", path)
|
||||
return {
|
||||
"immich_url": opts.get("immich_url") or os.environ.get("IMMICH_URL", ""),
|
||||
"immich_api_key": opts.get("immich_api_key") or os.environ.get("IMMICH_API_KEY", ""),
|
||||
}
|
||||
|
||||
|
||||
OPTIONS = load_options()
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def ingress_only(request, handler):
|
||||
# Only the Supervisor ingress proxy may talk to us; it has already
|
||||
# authenticated the Home Assistant user.
|
||||
if not MOCK and request.remote != INGRESS_PROXY_IP:
|
||||
return web.Response(status=403, text="Access only via Home Assistant ingress")
|
||||
response = await handler(request)
|
||||
# Revalidate the UI files so an add-on update shows up immediately.
|
||||
if isinstance(response, web.FileResponse):
|
||||
response.headers.setdefault("Cache-Control", "no-cache")
|
||||
return response
|
||||
|
||||
|
||||
def load_store():
|
||||
if STORE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STORE_FILE.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
LOG.exception("Could not read %s, starting empty", STORE_FILE)
|
||||
return {"version": 1, "dashboards": []}
|
||||
|
||||
|
||||
def save_store(data):
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STORE_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2))
|
||||
tmp.replace(STORE_FILE)
|
||||
|
||||
|
||||
async def index(request):
|
||||
return web.FileResponse(WEB_DIR / "index.html")
|
||||
|
||||
|
||||
async def get_dashboards(request):
|
||||
return web.json_response(load_store())
|
||||
|
||||
|
||||
async def put_dashboards(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"error": "invalid json"}, status=400)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("dashboards"), list):
|
||||
return web.json_response({"error": "expected {dashboards: [...]}"}, status=400)
|
||||
save_store(data)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
|
||||
async def camera(request):
|
||||
entity_id = request.match_info["entity_id"]
|
||||
if not entity_id.startswith("camera.") or "/" in entity_id:
|
||||
return web.Response(status=400)
|
||||
if MOCK:
|
||||
return web.Response(body=request.app["mock"].camera_svg(entity_id), content_type="image/svg+xml")
|
||||
session = request.app["http"]
|
||||
async with session.get(
|
||||
f"{HA_API_URL}/camera_proxy/{entity_id}",
|
||||
headers={"Authorization": f"Bearer {SUPERVISOR_TOKEN}"},
|
||||
) as resp:
|
||||
body = await resp.read()
|
||||
return web.Response(
|
||||
status=resp.status,
|
||||
body=body,
|
||||
content_type=resp.content_type or "image/jpeg",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
async def websocket(request):
|
||||
client = web.WebSocketResponse(heartbeat=30, max_msg_size=0)
|
||||
await client.prepare(request)
|
||||
|
||||
if MOCK:
|
||||
await request.app["mock"].serve(client)
|
||||
return client
|
||||
if not SUPERVISOR_TOKEN:
|
||||
await client.send_json({"type": "error", "message": "Dashboard Studio has no access to Home Assistant (SUPERVISOR_TOKEN is missing). Restart the app; if this stays, reinstall it."})
|
||||
await client.close()
|
||||
return client
|
||||
|
||||
session = request.app["http"]
|
||||
try:
|
||||
upstream = await session.ws_connect(HA_WS_URL, heartbeat=30, max_msg_size=0)
|
||||
except aiohttp.ClientError as err:
|
||||
LOG.error("Cannot reach Home Assistant websocket: %s", err)
|
||||
await client.send_json({"type": "error", "message": "Cannot reach Home Assistant"})
|
||||
await client.close()
|
||||
return client
|
||||
|
||||
try:
|
||||
msg = await upstream.receive_json() # auth_required
|
||||
await upstream.send_json({"type": "auth", "access_token": SUPERVISOR_TOKEN})
|
||||
msg = await upstream.receive_json()
|
||||
if msg.get("type") != "auth_ok":
|
||||
LOG.error("Home Assistant rejected the Supervisor token: %s", msg)
|
||||
await client.send_json({"type": "error", "message": "Home Assistant auth failed"})
|
||||
return client
|
||||
await client.send_json({"type": "ready", "ha_version": msg.get("ha_version")})
|
||||
|
||||
async def pump(src, dst):
|
||||
async for m in src:
|
||||
if m.type == aiohttp.WSMsgType.TEXT:
|
||||
await dst.send_str(m.data)
|
||||
elif m.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSE):
|
||||
break
|
||||
|
||||
tasks = [asyncio.create_task(pump(client, upstream)), asyncio.create_task(pump(upstream, client))]
|
||||
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
finally:
|
||||
await upstream.close()
|
||||
await client.close()
|
||||
return client
|
||||
|
||||
|
||||
async def on_startup(app):
|
||||
app["http"] = aiohttp.ClientSession()
|
||||
await app["immich"].start()
|
||||
if MOCK:
|
||||
app["mock"] = MockHA()
|
||||
app["mock_task"] = asyncio.create_task(app["mock"].simulate())
|
||||
LOG.warning("No SUPERVISOR_TOKEN: running against mock Home Assistant")
|
||||
|
||||
|
||||
async def on_cleanup(app):
|
||||
await app["http"].close()
|
||||
await app["immich"].close()
|
||||
if MOCK:
|
||||
app["mock_task"].cancel()
|
||||
|
||||
|
||||
def make_app():
|
||||
app = web.Application(middlewares=[ingress_only], client_max_size=8 * 1024 * 1024)
|
||||
app.router.add_get("/", index)
|
||||
app.router.add_get("/api/dashboards", get_dashboards)
|
||||
app.router.add_put("/api/dashboards", put_dashboards)
|
||||
app.router.add_get("/api/camera/{entity_id}", camera)
|
||||
app.router.add_get("/ws", websocket)
|
||||
app["immich"] = Immich(OPTIONS["immich_url"], OPTIONS["immich_api_key"], mock=MOCK)
|
||||
app.router.add_routes(app["immich"].routes())
|
||||
app.router.add_static("/static", WEB_DIR)
|
||||
app.on_startup.append(on_startup)
|
||||
app.on_cleanup.append(on_cleanup)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if IN_HOME_ASSISTANT and not SUPERVISOR_TOKEN:
|
||||
LOG.error("SUPERVISOR_TOKEN is missing; the app can't talk to Home Assistant")
|
||||
LOG.info("Dashboard Studio listening on port %s (data in %s)", PORT, DATA_DIR)
|
||||
web.run_app(make_app(), port=PORT, access_log=None)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""A tiny fake of the Home Assistant websocket API for local development."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aiohttp import WSMsgType
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def entity(entity_id, state, **attrs):
|
||||
ts = now_iso()
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"state": state,
|
||||
"attributes": attrs,
|
||||
"last_changed": ts,
|
||||
"last_updated": ts,
|
||||
"context": {"id": "mock"},
|
||||
}
|
||||
|
||||
|
||||
class MockHA:
|
||||
def __init__(self):
|
||||
today = datetime.now().astimezone().date()
|
||||
|
||||
def pickup(kind, days):
|
||||
# Mirrors the Afvalwijzer integration: ISO date state plus collection-date attributes.
|
||||
if days is None:
|
||||
return entity(f"sensor.afvalwijzer_1234ab_12_{kind}", "geen", friendly_name=f"Afvalwijzer {kind}", last_update=now_iso(),
|
||||
days_until_collection_date=None, is_collection_date_today=False, is_collection_date_tomorrow=False, is_collection_date_day_after_tomorrow=False)
|
||||
return entity(f"sensor.afvalwijzer_1234ab_12_{kind}", (today + timedelta(days=days)).isoformat(), friendly_name=f"Afvalwijzer {kind}", last_update=now_iso(),
|
||||
days_until_collection_date=days, is_collection_date_today=days == 0, is_collection_date_tomorrow=days == 1, is_collection_date_day_after_tomorrow=days == 2)
|
||||
|
||||
waste = [pickup("gft", 1), pickup("pmd", 1), pickup("papier", 6), pickup("restafval", 8), pickup("textiel", 23), pickup("kerstbomen", None),
|
||||
entity("sensor.afvalwijzer_1234ab_12_tomorrow", "gft, pmd", friendly_name="Afvalwijzer Morgen"),
|
||||
entity("sensor.afvalwijzer_1234ab_12_next_in_days", "1", friendly_name="Afvalwijzer Dagen tot volgende ophaling")]
|
||||
self.states = {
|
||||
e["entity_id"]: e
|
||||
for e in waste + [
|
||||
entity("light.living_room", "on", friendly_name="Living Room", brightness=180, supported_color_modes=["brightness"]),
|
||||
entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"]),
|
||||
entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["brightness"]),
|
||||
entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]),
|
||||
entity("switch.coffee_machine", "off", friendly_name="Coffee Machine"),
|
||||
entity("fan.office", "off", friendly_name="Office Fan"),
|
||||
entity("sensor.living_room_temperature", "21.4", friendly_name="Living Room Temperature", unit_of_measurement="°C", device_class="temperature"),
|
||||
entity("sensor.outdoor_temperature", "12.8", friendly_name="Outdoor Temperature", unit_of_measurement="°C", device_class="temperature"),
|
||||
entity("sensor.living_room_humidity", "48", friendly_name="Living Room Humidity", unit_of_measurement="%", device_class="humidity"),
|
||||
entity("sensor.power_usage", "640", friendly_name="Power Usage", unit_of_measurement="W", device_class="power"),
|
||||
entity("sensor.solar_production", "1820", friendly_name="Solar Production", unit_of_measurement="W", device_class="power"),
|
||||
entity("sensor.phone_battery", "76", friendly_name="Phone Battery", unit_of_measurement="%", device_class="battery"),
|
||||
entity("binary_sensor.front_door", "off", friendly_name="Front Door", device_class="door"),
|
||||
entity("binary_sensor.hallway_motion", "off", friendly_name="Hallway Motion", device_class="motion"),
|
||||
entity("climate.thermostat", "heat", friendly_name="Thermostat", current_temperature=20.5, temperature=21, min_temp=7, max_temp=30, hvac_modes=["off", "heat", "auto"]),
|
||||
entity("cover.garage_door", "closed", friendly_name="Garage Door"),
|
||||
entity("lock.front_door", "locked", friendly_name="Front Door Lock"),
|
||||
entity("media_player.living_room_speaker", "playing", friendly_name="Living Room Speaker", media_title="Blue in Green", media_artist="Miles Davis", volume_level=0.35),
|
||||
entity("scene.movie_night", "2026-10-24T20:00:00+00:00", friendly_name="Movie Night"),
|
||||
entity("script.good_morning", "off", friendly_name="Good Morning"),
|
||||
entity("weather.home", "partlycloudy", friendly_name="Home", temperature=13, humidity=71, wind_speed=14),
|
||||
entity("camera.front_yard", "idle", friendly_name="Front Yard"),
|
||||
entity("person.alex", "home", friendly_name="Alex"),
|
||||
entity("input_boolean.guest_mode", "off", friendly_name="Guest Mode"),
|
||||
entity("calendar.family", "off", friendly_name="Family"),
|
||||
entity("calendar.work", "on", friendly_name="Work"),
|
||||
entity("sensor.model_y_battery", "68", friendly_name="Model Y Battery", unit_of_measurement="%", device_class="battery"),
|
||||
entity("sensor.model_y_range", "341", friendly_name="Model Y Range", unit_of_measurement="km", device_class="distance"),
|
||||
entity("binary_sensor.model_y_charging", "on", friendly_name="Model Y Charging", device_class="battery_charging"),
|
||||
entity("sensor.model_y_charging_power", "7.4", friendly_name="Model Y Charging Power", unit_of_measurement="kW", device_class="power"),
|
||||
entity("binary_sensor.model_y_plug", "on", friendly_name="Model Y Plug", device_class="plug"),
|
||||
entity("number.model_y_charge_limit", "80", friendly_name="Model Y Charge Limit", unit_of_measurement="%"),
|
||||
entity("lock.model_y_doors", "locked", friendly_name="Model Y Doors"),
|
||||
entity("climate.model_y_climate", "off", friendly_name="Model Y Climate", current_temperature=14, temperature=21, hvac_modes=["off", "heat_cool"]),
|
||||
entity("device_tracker.model_y_location", "home", friendly_name="Model Y Location"),
|
||||
entity("sensor.model_y_odometer", "24816", friendly_name="Model Y Odometer", unit_of_measurement="km", device_class="distance"),
|
||||
]
|
||||
}
|
||||
self.clients = set()
|
||||
|
||||
# -- state handling ---------------------------------------------------
|
||||
|
||||
def set_state(self, entity_id, state=None, **attrs):
|
||||
old = self.states[entity_id]
|
||||
new = json.loads(json.dumps(old))
|
||||
if state is not None and state != old["state"]:
|
||||
new["state"] = state
|
||||
new["last_changed"] = now_iso()
|
||||
new["attributes"].update(attrs)
|
||||
new["last_updated"] = now_iso()
|
||||
self.states[entity_id] = new
|
||||
event = {"event_type": "state_changed", "data": {"entity_id": entity_id, "old_state": old, "new_state": new}, "time_fired": now_iso()}
|
||||
for ws, sub_ids in list(self.clients):
|
||||
for sid in sub_ids:
|
||||
asyncio.ensure_future(self._safe_send(ws, {"id": sid, "type": "event", "event": event}))
|
||||
|
||||
async def _safe_send(self, ws, payload):
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception: # client went away
|
||||
pass
|
||||
|
||||
def call_service(self, domain, service, data):
|
||||
targets = data.get("entity_id") or (data.get("target") or {}).get("entity_id") or []
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
for eid in targets:
|
||||
if eid not in self.states:
|
||||
continue
|
||||
st = self.states[eid]["state"]
|
||||
if eid.startswith("climate.") and service in ("turn_on", "turn_off", "toggle"):
|
||||
on = service == "turn_on" or (service == "toggle" and st == "off")
|
||||
self.set_state(eid, (self.states[eid]["attributes"].get("hvac_modes") or ["off", "heat"])[-1] if on else "off")
|
||||
elif service in ("turn_on", "turn_off", "toggle"):
|
||||
on = service == "turn_on" or (service == "toggle" and st != "on")
|
||||
extra = {}
|
||||
if eid.startswith("light."):
|
||||
if "brightness" in data:
|
||||
extra["brightness"] = data["brightness"]
|
||||
elif "brightness_pct" in data:
|
||||
extra["brightness"] = round(data["brightness_pct"] * 2.55)
|
||||
elif on and not self.states[eid]["attributes"].get("brightness"):
|
||||
extra["brightness"] = 255
|
||||
if not on:
|
||||
extra["brightness"] = None
|
||||
self.set_state(eid, "on" if on else "off", **extra)
|
||||
elif service == "set_temperature":
|
||||
self.set_state(eid, None, temperature=data.get("temperature"))
|
||||
elif service == "set_hvac_mode":
|
||||
self.set_state(eid, data.get("hvac_mode"))
|
||||
elif service in ("open_cover", "close_cover"):
|
||||
self.set_state(eid, "open" if service == "open_cover" else "closed")
|
||||
elif service == "toggle_cover" or (domain == "cover" and service == "toggle"):
|
||||
self.set_state(eid, "closed" if st == "open" else "open")
|
||||
elif service in ("lock", "unlock"):
|
||||
self.set_state(eid, "locked" if service == "lock" else "unlocked")
|
||||
elif service == "media_play_pause":
|
||||
self.set_state(eid, "paused" if st == "playing" else "playing")
|
||||
elif service == "volume_set":
|
||||
self.set_state(eid, None, volume_level=data.get("volume_level"))
|
||||
elif domain in ("scene", "script") and service in ("turn_on",):
|
||||
self.set_state(eid, now_iso() if domain == "scene" else "off")
|
||||
|
||||
def calendar_events(self, entity_ids):
|
||||
"""A repeatable week of events relative to today."""
|
||||
today = datetime.now().astimezone().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
plan = {
|
||||
"calendar.family": [(0, 18, 30, 90, "Dinner with grandparents", "Home"), (1, None, 0, 0, "Bin collection", ""), (2, 16, 0, 60, "Swimming lessons", "Sports centre"), (5, None, 0, 0, "Emma’s birthday", ""), (6, 10, 0, 120, "Market", "Town square")],
|
||||
"calendar.work": [(0, 9, 0, 30, "Stand-up", ""), (0, 14, 0, 60, "Design review", "Room 3"), (1, 11, 0, 45, "1:1 with Sam", ""), (3, 13, 30, 90, "Quarterly planning", "Main office")],
|
||||
}
|
||||
out = {}
|
||||
for eid in entity_ids:
|
||||
events = []
|
||||
for day, hour, minute, dur, summary, location in plan.get(eid, []):
|
||||
d = today + timedelta(days=day)
|
||||
if hour is None:
|
||||
events.append({"start": d.date().isoformat(), "end": (d + timedelta(days=1)).date().isoformat(), "summary": summary})
|
||||
else:
|
||||
start = d.replace(hour=hour, minute=minute)
|
||||
events.append({"start": start.isoformat(), "end": (start + timedelta(minutes=dur)).isoformat(), "summary": summary, "location": location})
|
||||
out[eid] = {"events": events}
|
||||
return out
|
||||
|
||||
def history(self, entity_ids, start):
|
||||
out = {}
|
||||
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||
span = (datetime.now(timezone.utc) - start_dt).total_seconds()
|
||||
for eid in entity_ids:
|
||||
st = self.states.get(eid)
|
||||
if not st:
|
||||
continue
|
||||
try:
|
||||
base = float(st["state"])
|
||||
except ValueError:
|
||||
out[eid] = [{"s": st["state"], "lu": start_dt.timestamp()}]
|
||||
continue
|
||||
pts = []
|
||||
n = 96
|
||||
seed = sum(map(ord, eid))
|
||||
for i in range(n):
|
||||
t = start_dt.timestamp() + span * i / (n - 1)
|
||||
wave = math.sin(i / 10 + seed) * 0.08 + math.sin(i / 3.3 + seed) * 0.03
|
||||
pts.append({"s": f"{base * (1 + wave):.1f}", "lu": t})
|
||||
pts[-1]["s"] = st["state"]
|
||||
out[eid] = pts
|
||||
return out
|
||||
|
||||
def camera_svg(self, entity_id):
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
return f"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#4b6584"/><stop offset="1" stop-color="#1e272e"/></linearGradient></defs>
|
||||
<rect width="640" height="360" fill="url(#g)"/>
|
||||
<rect x="0" y="250" width="640" height="110" fill="#2d3e2f"/>
|
||||
<polygon points="180,250 260,170 340,250" fill="#57606f"/><rect x="200" y="250" width="120" height="70" fill="#747d8c"/>
|
||||
<text x="16" y="30" fill="#fff" font-family="monospace" font-size="18">{entity_id} · {stamp}</text>
|
||||
</svg>""".encode()
|
||||
|
||||
async def simulate(self):
|
||||
while True:
|
||||
await asyncio.sleep(3)
|
||||
for eid, spread in (
|
||||
("sensor.power_usage", 60),
|
||||
("sensor.solar_production", 80),
|
||||
("sensor.living_room_temperature", 0.1),
|
||||
("sensor.outdoor_temperature", 0.2),
|
||||
):
|
||||
v = float(self.states[eid]["state"]) + random.uniform(-spread, spread)
|
||||
self.set_state(eid, f"{max(v, 0):.1f}" if spread < 1 else f"{max(v, 0):.0f}")
|
||||
if self.states["binary_sensor.model_y_charging"]["state"] == "on":
|
||||
lvl = float(self.states["sensor.model_y_battery"]["state"])
|
||||
if lvl < 80:
|
||||
self.set_state("sensor.model_y_battery", f"{lvl + 0.2:.1f}")
|
||||
if random.random() < 0.15:
|
||||
eid = "binary_sensor.hallway_motion"
|
||||
self.set_state(eid, "off" if self.states[eid]["state"] == "on" else "on")
|
||||
|
||||
# -- websocket ----------------------------------------------------------
|
||||
|
||||
async def serve(self, ws):
|
||||
subs = set()
|
||||
entry = (ws, frozenset())
|
||||
await ws.send_json({"type": "ready", "ha_version": "mock"})
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type != WSMsgType.TEXT:
|
||||
continue
|
||||
req = json.loads(msg.data)
|
||||
mid, typ = req.get("id"), req.get("type")
|
||||
if typ == "get_states":
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": list(self.states.values())})
|
||||
elif typ == "subscribe_events":
|
||||
subs.add(mid)
|
||||
self.clients.discard(entry)
|
||||
entry = (ws, frozenset(subs))
|
||||
self.clients.add(entry)
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": None})
|
||||
elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events":
|
||||
ids = (req.get("target") or {}).get("entity_id") or []
|
||||
ids = [ids] if isinstance(ids, str) else ids
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": self.calendar_events(ids)}})
|
||||
elif typ == "call_service":
|
||||
data = dict(req.get("service_data") or {})
|
||||
if req.get("target"):
|
||||
data.setdefault("entity_id", req["target"].get("entity_id"))
|
||||
self.call_service(req["domain"], req["service"], data)
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}}})
|
||||
elif typ == "history/history_during_period":
|
||||
start = req.get("start_time") or (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
||||
await ws.send_json({"id": mid, "type": "result", "success": True, "result": self.history(req.get("entity_ids", []), start)})
|
||||
elif typ == "ping":
|
||||
await ws.send_json({"id": mid, "type": "pong"})
|
||||
else:
|
||||
await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "unknown_command", "message": typ}})
|
||||
finally:
|
||||
self.clients.discard(entry)
|
||||
Reference in New Issue
Block a user