Dashboard Studio 1.12.0: new cards, kiosk doorbell, night mode, editor tools, version history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:12:30 +01:00
parent f9822a9c49
commit 7abf7379d8
11 changed files with 1728 additions and 182 deletions
+109 -7
View File
@@ -4,6 +4,7 @@ import asyncio
import json
import math
import random
import time
from datetime import datetime, timedelta, timezone
from aiohttp import WSMsgType
@@ -43,11 +44,12 @@ class MockHA:
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.living_room", "on", friendly_name="Living Room", brightness=180, supported_color_modes=["color_temp", "rgb"], color_mode="color_temp",
color_temp_kelvin=2700, rgb_color=[255, 167, 87], min_color_temp_kelvin=2000, max_color_temp_kelvin=6500),
entity("light.reading_lamp", "on", friendly_name="Reading Lamp", brightness=140, rgb_color=[255, 140, 60], supported_color_modes=["rgb"], color_mode="rgb"),
entity("light.kitchen", "off", friendly_name="Kitchen", brightness=None, supported_color_modes=["color_temp"], min_color_temp_kelvin=2200, max_color_temp_kelvin=6500),
entity("light.bedroom", "on", friendly_name="Bedroom", brightness=90, supported_color_modes=["brightness"]),
entity("light.downstairs", "on", friendly_name="Downstairs", brightness=160, supported_color_modes=["brightness"],
entity("light.downstairs", "on", friendly_name="Downstairs", brightness=160, supported_color_modes=["color_temp", "rgb"],
entity_id=["light.living_room", "light.kitchen", "light.reading_lamp"]),
entity("vacuum.robot", "docked", friendly_name="Robot Vacuum", battery_level=82, fan_speed="standard",
fan_speed_list=["quiet", "standard", "turbo", "max"], status="Charging"),
@@ -63,6 +65,7 @@ class MockHA:
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.doorbell", "off", friendly_name="Doorbell", device_class="occupancy"),
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"],
preset_modes=["none", "eco", "comfort", "away"], preset_mode="comfort", fan_modes=["auto", "low", "high"], fan_mode="auto", hvac_action="heating", current_humidity=46, target_temp_step=0.5),
@@ -101,7 +104,17 @@ class MockHA:
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", user_id="mock-user-alex"),
entity("person.alex", "home", friendly_name="Alex", user_id="mock-user-alex", entity_picture="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='%23f2b880'/><circle cx='32' cy='26' r='12' fill='%23fff3e6'/><path d='M10 64c2-14 12-20 22-20s20 6 22 20z' fill='%23fff3e6'/></svg>"),
entity("person.noor", "Work", friendly_name="Noor"),
entity("todo.shopping_list", "3", friendly_name="Shopping list"),
entity("cover.living_room_blinds", "open", friendly_name="Living Room Blinds", current_position=60, current_tilt_position=30),
entity("cover.bedroom_curtain", "closed", friendly_name="Bedroom Curtain", current_position=0),
entity("alarm_control_panel.home", "disarmed", friendly_name="Home Alarm", code_format="number", supported_features=7),
entity("update.home_assistant_core_update", "on", friendly_name="Home Assistant Core Update", title="Home Assistant Core",
installed_version="2026.10.1", latest_version="2026.10.3", release_url="https://www.home-assistant.io/blog/"),
entity("timer.kitchen", "idle", friendly_name="Kitchen timer", duration="0:10:00", remaining="0:10:00"),
entity("sun.sun", "above_horizon", friendly_name="Sun",
next_rising=(datetime.now(timezone.utc) + timedelta(hours=9)).isoformat(), next_setting=(datetime.now(timezone.utc) + timedelta(hours=3)).isoformat()),
entity("person.sam", "not_home", friendly_name="Sam", user_id="mock-user-sam"),
entity("input_boolean.guest_mode", "off", friendly_name="Guest Mode"),
entity("calendar.family", "off", friendly_name="Family"),
@@ -113,12 +126,22 @@ class MockHA:
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("sensor.model_y_time_to_full", "48", friendly_name="Model Y Time to full", unit_of_measurement="min", device_class="duration"),
entity("switch.model_y_charger", "on", friendly_name="Model Y Charger"),
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()
self.todo = [{"uid": "1", "summary": "Milk", "status": "needs_action"}, {"uid": "2", "summary": "Bread", "status": "needs_action"},
{"uid": "3", "summary": "Coffee beans", "status": "needs_action"}, {"uid": "4", "summary": "Batteries", "status": "completed"}]
self._timer_handle = None
self.notes = {
"welcome": {"notification_id": "welcome", "title": "New devices found", "message": "Home Assistant found **2 new devices**. [Check them](/config/integrations).", "created_at": now_iso()},
"backup": {"notification_id": "backup", "title": "Backup finished", "message": "Last night's backup completed.", "created_at": now_iso()},
}
self.note_subs = set()
# -- state handling ---------------------------------------------------
@@ -144,6 +167,24 @@ class MockHA:
except Exception: # client went away
pass
def _timer(self, eid, service, data):
a = self.states[eid]["attributes"]
secs = lambda v: sum(int(x) * m for x, m in zip(reversed(str(v).split(":")), (1, 60, 3600)))
fmt = lambda n: f"{n // 3600}:{n % 3600 // 60:02d}:{n % 60:02d}"
if self._timer_handle:
self._timer_handle.cancel()
if service == "start":
dur = secs(data["duration"]) if data.get("duration") else (secs(a["remaining"]) if self.states[eid]["state"] == "paused" else secs(a["duration"]))
total = secs(data["duration"]) if data.get("duration") else secs(a["duration"])
fin = datetime.now(timezone.utc) + timedelta(seconds=dur)
self.set_state(eid, "active", duration=fmt(total), remaining=fmt(dur), finishes_at=fin.isoformat())
self._timer_handle = asyncio.get_event_loop().call_later(dur, lambda: self.set_state(eid, "idle", remaining=fmt(total), finishes_at=None))
elif service == "pause":
left = max(0, int((datetime.fromisoformat(a["finishes_at"]) - datetime.now(timezone.utc)).total_seconds())) if a.get("finishes_at") else secs(a["remaining"])
self.set_state(eid, "paused", remaining=fmt(left), finishes_at=None)
elif service in ("cancel", "finish"):
self.set_state(eid, "idle", remaining=a["duration"], finishes_at=None)
def _vacuum(self, eid, service, data):
if service == "start":
self.set_state(eid, "cleaning", status="Cleaning")
@@ -175,6 +216,35 @@ class MockHA:
for eid in expanded:
if eid not in self.states:
continue
if domain == "todo":
item = data.get("item")
if service == "add_item":
self.todo.insert(0, {"uid": str(int(time.time() * 1000)), "summary": item, "status": "needs_action"})
for it in list(self.todo):
if it["uid"] == item or it["summary"] == item:
if service == "update_item":
it["status"] = data.get("status", it["status"])
elif service == "remove_item":
self.todo.remove(it)
self.set_state(eid, str(sum(1 for it in self.todo if it["status"] == "needs_action")))
continue
if domain == "cover":
pos = self.states[eid]["attributes"].get("current_position")
if service == "set_cover_position":
p_ = int(data.get("position", 0)); self.set_state(eid, "open" if p_ > 0 else "closed", current_position=p_)
elif service == "set_cover_tilt_position":
self.set_state(eid, None, current_tilt_position=int(data.get("tilt_position", 0)))
elif service in ("open_cover", "close_cover"):
self.set_state(eid, "open" if service == "open_cover" else "closed", current_position=100 if service == "open_cover" else 0)
continue
if domain == "alarm_control_panel":
if service != "alarm_disarm" and data.get("code") not in (None, "1234") or service == "alarm_disarm" and data.get("code") != "1234":
raise ValueError("Invalid code")
self.set_state(eid, {"alarm_arm_home": "armed_home", "alarm_arm_away": "armed_away", "alarm_arm_night": "armed_night", "alarm_disarm": "disarmed"}[service])
continue
if domain == "timer":
self._timer(eid, service, data)
continue
if eid.startswith("button.dishwasher_"): # Home Connect program buttons
st_ = {"stop": "aborting", "pause": "pause", "resume": "run"}[eid.split("_")[-2]]
self.set_state("sensor.dishwasher_operation_state", st_)
@@ -198,6 +268,10 @@ class MockHA:
extra["brightness"] = round(data["brightness_pct"] * 2.55)
elif on and not self.states[eid]["attributes"].get("brightness"):
extra["brightness"] = 255
if "rgb_color" in data:
extra.update(rgb_color=data["rgb_color"], color_mode="rgb")
if "color_temp_kelvin" in data:
extra.update(color_temp_kelvin=data["color_temp_kelvin"], color_mode="color_temp")
if not on:
extra["brightness"] = None
self.set_state(eid, "on" if on else "off", **extra)
@@ -323,12 +397,35 @@ class MockHA:
entry = (ws, frozenset(subs))
self.clients.add(entry)
await ws.send_json({"id": mid, "type": "result", "success": True, "result": None})
elif typ == "persistent_notification/subscribe":
self.note_subs.add((ws, mid))
await ws.send_json({"id": mid, "type": "result", "success": True, "result": None})
await ws.send_json({"id": mid, "type": "event", "event": {"type": "current", "notifications": self.notes}})
elif typ == "call_service" and req.get("domain") == "persistent_notification":
gone = dict(self.notes) if req["service"] == "dismiss_all" else {k: v for k, v in self.notes.items() if k == (req.get("service_data") or {}).get("notification_id")}
for k in gone:
self.notes.pop(k, None)
for w2, sid in list(self.note_subs):
asyncio.ensure_future(self._safe_send(w2, {"id": sid, "type": "event", "event": {"type": "removed", "notifications": gone}}))
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}}})
elif typ == "todo/item/list":
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"items": self.todo}})
elif typ == "call_service" and req.get("domain") == "weather" and req.get("service") == "get_forecasts" and (req.get("service_data") or {}).get("type") == "hourly":
ids = (req.get("target") or {}).get("entity_id") or []
ids = [ids] if isinstance(ids, str) else ids
hour = datetime.now().astimezone().replace(minute=0, second=0, microsecond=0)
conds = ["partlycloudy", "partlycloudy", "cloudy", "rainy", "rainy", "cloudy", "partlycloudy", "sunny"]
forecast = [{"datetime": (hour + timedelta(hours=i + 1)).isoformat(), "condition": conds[i % 8], "temperature": round(13 + 3 * math.sin(i / 4), 1),
"precipitation_probability": [0, 10, 30, 70, 80, 40, 10, 0][i % 8]} for i in range(24)]
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": {e: {"forecast": forecast} for e in ids}}})
elif typ == "call_service" and req.get("domain") == "weather" and req.get("service") == "get_forecasts":
ids = (req.get("target") or {}).get("entity_id") or []
ids = [ids] if isinstance(ids, str) else ids
day = datetime.now().astimezone().replace(hour=12, minute=0, second=0, microsecond=0)
forecast = [{"datetime": (day + timedelta(days=i)).isoformat(), "condition": c, "temperature": hi, "templow": lo}
for i, (c, hi, lo) in enumerate([("partlycloudy", 15, 8), ("rainy", 12, 7), ("sunny", 16, 6)])]
for i, (c, hi, lo) in enumerate([("partlycloudy", 15, 8), ("rainy", 12, 7), ("sunny", 16, 6), ("cloudy", 14, 9), ("pouring", 11, 8), ("partlycloudy", 13, 6), ("sunny", 17, 7)])]
for i, f in enumerate(forecast):
f["precipitation_probability"] = [10, 80, 0, 20, 90, 30, 0][i]
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {"context": {"id": "mock"}, "response": {e: {"forecast": forecast} for e in ids}}})
elif typ == "call_service" and req.get("domain") == "calendar" and req.get("service") == "get_events":
ids = (req.get("target") or {}).get("entity_id") or []
@@ -338,7 +435,11 @@ class MockHA:
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)
try:
self.call_service(req["domain"], req["service"], data)
except ValueError as err:
await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "service_validation_error", "message": str(err)}})
continue
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()
@@ -371,3 +472,4 @@ class MockHA:
await ws.send_json({"id": mid, "type": "result", "success": False, "error": {"code": "unknown_command", "message": typ}})
finally:
self.clients.discard(entry)
self.note_subs = {x for x in self.note_subs if x[0] is not ws}