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:
2026-10-25 22:02:23 +01:00
commit e39a3d93f7
19 changed files with 3794 additions and 0 deletions
+159
View File
@@ -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),
]
+210
View File
@@ -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)
+260
View File
@@ -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)