From f5b7c53f1819e29bb548de7a85d49a24886c390b Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 2 Mar 2026 19:30:20 +0100 Subject: [PATCH] HA controls + wider layout: toggle lights/switches, cover controls, more sensors - Backend: call_ha_service() for controlling entities via HA REST API - Backend: POST /api/ha/control with JWT auth + cache invalidation - Backend: Parse switches, binary_sensors, humidity, climate entities - Frontend: HA card now xl:col-span-2 (double width) - Frontend: Interactive toggles for lights/switches, cover up/stop/down - Frontend: Temperature + humidity sensors, climate display, binary sensors - Frontend: Two-column internal layout (controls left, sensors right) Co-Authored-By: Claude Opus 4.6 --- server/routers/homeassistant.py | 62 +++- server/services/ha_service.py | 204 ++++++++++-- web/src/api.ts | 74 ++++- web/src/components/HomeAssistant.tsx | 479 ++++++++++++++++++++------- web/src/mockData.ts | 28 +- web/src/pages/Dashboard.tsx | 4 +- 6 files changed, 683 insertions(+), 168 deletions(-) diff --git a/server/routers/homeassistant.py b/server/routers/homeassistant.py index f95e44a..a99f31f 100644 --- a/server/routers/homeassistant.py +++ b/server/routers/homeassistant.py @@ -1,15 +1,17 @@ -"""Home Assistant data router.""" +"""Home Assistant data router — read states & control entities.""" from __future__ import annotations import logging -from typing import Any, Dict +from typing import Any, Dict, Optional -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from server.auth import require_admin from server.cache import cache from server.config import get_settings -from server.services.ha_service import fetch_ha_data +from server.services.ha_service import call_ha_service, fetch_ha_data logger = logging.getLogger(__name__) @@ -18,22 +20,18 @@ router = APIRouter(prefix="/api", tags=["homeassistant"]) CACHE_KEY = "ha" +# --------------------------------------------------------------------------- +# GET /api/ha — read-only, no auth needed +# --------------------------------------------------------------------------- + @router.get("/ha") async def get_ha() -> Dict[str, Any]: - """Return Home Assistant entity data. + """Return Home Assistant entity data (cached).""" - The exact shape depends on what ``fetch_ha_data`` returns; on failure an - error stub is returned instead:: - - { "error": true, "message": "..." } - """ - - # --- cache hit? ----------------------------------------------------------- cached = await cache.get(CACHE_KEY) if cached is not None: return cached - # --- cache miss ----------------------------------------------------------- try: data: Dict[str, Any] = await fetch_ha_data( get_settings().ha_url, @@ -45,3 +43,41 @@ async def get_ha() -> Dict[str, Any]: await cache.set(CACHE_KEY, data, get_settings().ha_cache_ttl) return data + + +# --------------------------------------------------------------------------- +# POST /api/ha/control — requires auth +# --------------------------------------------------------------------------- + +class HAControlRequest(BaseModel): + entity_id: str + action: str # toggle, turn_on, turn_off, open, close, stop + data: Optional[Dict[str, Any]] = None + + +@router.post("/ha/control") +async def control_ha( + body: HAControlRequest, + admin_user: str = Depends(require_admin), # noqa: ARG001 +) -> Dict[str, Any]: + """Control a Home Assistant entity (toggle light, open cover, etc.).""" + + settings = get_settings() + + if not settings.ha_url or not settings.ha_token: + return {"ok": False, "error": "Home Assistant not configured"} + + result = await call_ha_service( + url=settings.ha_url, + token=settings.ha_token, + entity_id=body.entity_id, + action=body.action, + service_data=body.data, + ) + + # Invalidate cache so the next GET reflects the new state + if result.get("ok"): + await cache.invalidate(CACHE_KEY) + logger.info("[HA] Cache invalidated after %s on %s", body.action, body.entity_id) + + return result diff --git a/server/services/ha_service.py b/server/services/ha_service.py index dd1bba6..06e9f74 100644 --- a/server/services/ha_service.py +++ b/server/services/ha_service.py @@ -1,8 +1,18 @@ +"""Home Assistant integration – fetch entity states & call services.""" + from __future__ import annotations -import httpx +import logging from typing import Any, Dict, List, Optional +import httpx + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- def _friendly_name(entity: Dict[str, Any]) -> str: """Extract the friendly name from an entity's attributes, falling back to entity_id.""" @@ -11,9 +21,7 @@ def _friendly_name(entity: Dict[str, Any]) -> str: def _parse_light(entity: Dict[str, Any]) -> Dict[str, Any]: - """Parse a light entity into a normalised dictionary.""" attrs = entity.get("attributes", {}) - state = entity.get("state", "unknown") brightness_raw = attrs.get("brightness") brightness_pct: Optional[int] = None if brightness_raw is not None: @@ -21,36 +29,41 @@ def _parse_light(entity: Dict[str, Any]) -> Dict[str, Any]: brightness_pct = round(int(brightness_raw) / 255 * 100) except (ValueError, TypeError): brightness_pct = None - return { "entity_id": entity.get("entity_id", ""), "name": _friendly_name(entity), - "state": state, + "state": entity.get("state", "unknown"), "brightness": brightness_pct, "color_mode": attrs.get("color_mode"), } +def _parse_switch(entity: Dict[str, Any]) -> Dict[str, Any]: + return { + "entity_id": entity.get("entity_id", ""), + "name": _friendly_name(entity), + "state": entity.get("state", "unknown"), + } + + def _parse_cover(entity: Dict[str, Any]) -> Dict[str, Any]: - """Parse a cover entity into a normalised dictionary.""" attrs = entity.get("attributes", {}) return { "entity_id": entity.get("entity_id", ""), "name": _friendly_name(entity), "state": entity.get("state", "unknown"), "current_position": attrs.get("current_position"), + "device_class": attrs.get("device_class", ""), } def _parse_sensor(entity: Dict[str, Any]) -> Dict[str, Any]: - """Parse a temperature sensor entity into a normalised dictionary.""" attrs = entity.get("attributes", {}) state_value = entity.get("state", "unknown") try: state_value = round(float(state_value), 1) except (ValueError, TypeError): pass - return { "entity_id": entity.get("entity_id", ""), "name": _friendly_name(entity), @@ -60,30 +73,65 @@ def _parse_sensor(entity: Dict[str, Any]) -> Dict[str, Any]: } +def _parse_binary_sensor(entity: Dict[str, Any]) -> Dict[str, Any]: + attrs = entity.get("attributes", {}) + return { + "entity_id": entity.get("entity_id", ""), + "name": _friendly_name(entity), + "state": entity.get("state", "unknown"), + "device_class": attrs.get("device_class", ""), + } + + +def _parse_climate(entity: Dict[str, Any]) -> Dict[str, Any]: + attrs = entity.get("attributes", {}) + current_temp = attrs.get("current_temperature") + target_temp = attrs.get("temperature") + try: + current_temp = round(float(current_temp), 1) if current_temp is not None else None + except (ValueError, TypeError): + current_temp = None + try: + target_temp = round(float(target_temp), 1) if target_temp is not None else None + except (ValueError, TypeError): + target_temp = None + return { + "entity_id": entity.get("entity_id", ""), + "name": _friendly_name(entity), + "state": entity.get("state", "unknown"), + "current_temperature": current_temp, + "target_temperature": target_temp, + "hvac_modes": attrs.get("hvac_modes", []), + "humidity": attrs.get("current_humidity"), + } + + +# Sensor device_classes we care about +_SENSOR_CLASSES = {"temperature", "humidity"} + +# Binary sensor device_classes we display +_BINARY_SENSOR_CLASSES = {"door", "window", "motion", "occupancy"} + + +# --------------------------------------------------------------------------- +# Fetch all entity states +# --------------------------------------------------------------------------- + async def fetch_ha_data(url: str, token: str) -> Dict[str, Any]: - """Fetch and categorise entity states from a Home Assistant instance. + """Fetch and categorise entity states from a Home Assistant instance.""" - Args: - url: Base URL of the Home Assistant instance (e.g. ``http://192.168.1.100:8123``). - token: Long-lived access token for authentication. - - Returns: - Dictionary containing: - - ``online``: Whether the HA instance is reachable. - - ``lights``: List of light entities with state and brightness. - - ``covers``: List of cover entities with state and position. - - ``sensors``: List of temperature sensor entities. - - ``lights_on``: Count of lights currently in the ``on`` state. - - ``lights_total``: Total number of light entities. - - ``error``: Error message if the request failed, else ``None``. - """ result: Dict[str, Any] = { "online": False, "lights": [], + "switches": [], "covers": [], "sensors": [], + "binary_sensors": [], + "climate": [], "lights_on": 0, "lights_total": 0, + "switches_on": 0, + "switches_total": 0, "error": None, } @@ -115,8 +163,11 @@ async def fetch_ha_data(url: str, token: str) -> Dict[str, Any]: result["online"] = True lights: List[Dict[str, Any]] = [] + switches: List[Dict[str, Any]] = [] covers: List[Dict[str, Any]] = [] sensors: List[Dict[str, Any]] = [] + binary_sensors: List[Dict[str, Any]] = [] + climate_list: List[Dict[str, Any]] = [] for entity in entities: entity_id: str = entity.get("entity_id", "") @@ -130,20 +181,121 @@ async def fetch_ha_data(url: str, token: str) -> Dict[str, Any]: if domain == "light": lights.append(_parse_light(entity)) + elif domain == "switch": + switches.append(_parse_switch(entity)) + elif domain == "cover": covers.append(_parse_cover(entity)) elif domain == "sensor": device_class = attrs.get("device_class", "") - if device_class == "temperature": + if device_class in _SENSOR_CLASSES: sensors.append(_parse_sensor(entity)) - lights_on = sum(1 for light in lights if light["state"] == "on") + elif domain == "binary_sensor": + device_class = attrs.get("device_class", "") + if device_class in _BINARY_SENSOR_CLASSES: + binary_sensors.append(_parse_binary_sensor(entity)) + + elif domain == "climate": + climate_list.append(_parse_climate(entity)) result["lights"] = lights + result["switches"] = switches result["covers"] = covers result["sensors"] = sensors - result["lights_on"] = lights_on + result["binary_sensors"] = binary_sensors + result["climate"] = climate_list + result["lights_on"] = sum(1 for l in lights if l["state"] == "on") result["lights_total"] = len(lights) + result["switches_on"] = sum(1 for s in switches if s["state"] == "on") + result["switches_total"] = len(switches) + + logger.info( + "[HA] Fetched %d lights (%d on), %d switches (%d on), %d covers, " + "%d sensors, %d binary, %d climate", + len(lights), result["lights_on"], + len(switches), result["switches_on"], + len(covers), len(sensors), len(binary_sensors), len(climate_list), + ) return result + + +# --------------------------------------------------------------------------- +# Call HA service (control entities) +# --------------------------------------------------------------------------- + +# Map of (domain, action) → HA service name +_ACTION_MAP: Dict[tuple, str] = { + ("light", "toggle"): "toggle", + ("light", "turn_on"): "turn_on", + ("light", "turn_off"): "turn_off", + ("switch", "toggle"): "toggle", + ("switch", "turn_on"): "turn_on", + ("switch", "turn_off"): "turn_off", + ("cover", "open"): "open_cover", + ("cover", "close"): "close_cover", + ("cover", "stop"): "stop_cover", + ("climate", "turn_on"): "turn_on", + ("climate", "turn_off"): "turn_off", +} + + +async def call_ha_service( + url: str, + token: str, + entity_id: str, + action: str, + service_data: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Call a Home Assistant service to control an entity. + + Args: + url: Base URL of the HA instance. + token: Long-lived access token. + entity_id: e.g. ``light.wohnzimmer_deckenlampe`` + action: e.g. ``toggle``, ``turn_on``, ``turn_off``, ``open``, ``close``, ``stop`` + service_data: Optional extra data (e.g. brightness). + + Returns: + ``{"ok": True}`` on success, ``{"ok": False, "error": "..."}`` on failure. + """ + + if "." not in entity_id: + return {"ok": False, "error": f"Invalid entity_id: {entity_id}"} + + domain = entity_id.split(".")[0] + service = _ACTION_MAP.get((domain, action)) + if service is None: + return {"ok": False, "error": f"Unknown action '{action}' for domain '{domain}'"} + + base_url = url.rstrip("/") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + payload: Dict[str, Any] = {"entity_id": entity_id} + if service_data: + payload.update(service_data) + + try: + async with httpx.AsyncClient(timeout=10, verify=False) as client: + resp = await client.post( + f"{base_url}/api/services/{domain}/{service}", + headers=headers, + json=payload, + ) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + logger.error("[HA] Service call failed: HTTP %s for %s/%s %s", + exc.response.status_code, domain, service, entity_id) + return {"ok": False, "error": f"HTTP {exc.response.status_code}"} + except Exception as exc: + logger.error("[HA] Service call error: %s for %s/%s %s", + exc, domain, service, entity_id) + return {"ok": False, "error": str(exc)} + + logger.info("[HA] %s/%s → %s ✓", domain, service, entity_id) + return {"ok": True} diff --git a/web/src/api.ts b/web/src/api.ts index c8f3514..99ed019 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -66,13 +66,65 @@ export interface ServersResponse { servers: ServerStats[]; } +export interface HALight { + entity_id: string; + name: string; + state: string; + brightness: number | null; + color_mode?: string; +} + +export interface HASwitch { + entity_id: string; + name: string; + state: string; +} + +export interface HACover { + entity_id: string; + name: string; + state: string; + current_position: number | null; + device_class?: string; +} + +export interface HASensor { + entity_id: string; + name: string; + state: number | string; + unit: string; + device_class?: string; +} + +export interface HABinarySensor { + entity_id: string; + name: string; + state: string; + device_class: string; +} + +export interface HAClimate { + entity_id: string; + name: string; + state: string; + current_temperature: number | null; + target_temperature: number | null; + hvac_modes: string[]; + humidity: number | null; +} + export interface HAData { online: boolean; - lights: { entity_id: string; name: string; state: string; brightness: number }[]; - covers: { entity_id: string; name: string; state: string; position: number }[]; - sensors: { entity_id: string; name: string; state: number; unit: string }[]; + lights: HALight[]; + switches: HASwitch[]; + covers: HACover[]; + sensors: HASensor[]; + binary_sensors: HABinarySensor[]; + climate: HAClimate[]; lights_on: number; lights_total: number; + switches_on: number; + switches_total: number; error?: boolean; } @@ -130,6 +182,22 @@ export const fetchNews = (limit = 20, offset = 0, category?: string) => { }; export const fetchServers = () => fetchJSON("/servers"); export const fetchHA = () => fetchJSON("/ha"); +export const controlHA = async ( + entity_id: string, + action: string, + data?: Record, +): Promise<{ ok: boolean; error?: string }> => { + const token = localStorage.getItem("token"); + const res = await fetch(`${API_BASE}/ha/control`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ entity_id, action, data }), + }); + return res.json(); +}; export const fetchTasks = () => fetchJSON("/tasks"); export const fetchMqtt = () => fetchJSON("/mqtt"); export const fetchAll = () => fetchJSON("/all"); diff --git a/web/src/components/HomeAssistant.tsx b/web/src/components/HomeAssistant.tsx index a0267f6..91c05f3 100644 --- a/web/src/components/HomeAssistant.tsx +++ b/web/src/components/HomeAssistant.tsx @@ -1,16 +1,62 @@ -import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff } from "lucide-react"; +import { useState, useCallback } from "react"; +import { + Home, + Lightbulb, + Power, + ArrowUp, + ArrowDown, + Square, + Thermometer, + Droplets, + DoorOpen, + DoorClosed, + Eye, + Flame, + WifiOff, +} from "lucide-react"; import type { HAData } from "../api"; +import { controlHA } from "../api"; + +/* ── Types ───────────────────────────────────────────────── */ interface HomeAssistantProps { data: HAData; } +/* ── Main Component ──────────────────────────────────────── */ + export default function HomeAssistant({ data }: HomeAssistantProps) { + const [pending, setPending] = useState>(new Set()); + + const handleControl = useCallback( + async (entity_id: string, action: string) => { + setPending((p) => new Set(p).add(entity_id)); + try { + const result = await controlHA(entity_id, action); + if (!result.ok) { + console.error(`[HA] Control failed: ${result.error}`); + } + } catch (err) { + console.error("[HA] Control error:", err); + } finally { + // Small delay so HA can process, then let parent refresh + setTimeout(() => { + setPending((p) => { + const next = new Set(p); + next.delete(entity_id); + return next; + }); + }, 600); + } + }, + [], + ); + if (!data) return null; if (data.error) { return ( -
+
@@ -22,147 +68,342 @@ export default function HomeAssistant({ data }: HomeAssistantProps) { ); } + // Split sensors by type + const tempSensors = data.sensors.filter((s) => s.device_class === "temperature"); + const humiditySensors = data.sensors.filter((s) => s.device_class === "humidity"); + return ( -
+
{/* Header */}

Home Assistant

- -
-
- - {data.online ? "Online" : "Offline"} +
+ + {data.lights_on + data.switches_on} aktiv +
+
+ + {data.online ? "Online" : "Offline"} + +
-
- {/* Lights */} -
-
-
- - Lichter -
- - {data.lights_on}/{data.lights_total} - -
- - {data.lights.length > 0 ? ( -
- {data.lights.map((light) => { - const isOn = light.state === "on"; - return ( -
-
- - {light.name} - - {isOn && light.brightness > 0 && ( - - {Math.round((light.brightness / 255) * 100)}% - - )} -
- ); - })} -
- ) : ( -

Keine Lichter konfiguriert

- )} -
- - {/* Covers */} - {data.covers.length > 0 && ( + {/* Two-column layout */} +
+ {/* ── Left Column: Lights + Switches ────────────── */} +
+ {/* Lights */}
-
- - Rollos +
+
+ + Lichter +
+ + {data.lights_on}/{data.lights_total} +
-
- {data.covers.map((cover) => { - const isOpen = cover.state === "open"; - const isClosed = cover.state === "closed"; - return ( -
-
- {isOpen ? ( - - ) : isClosed ? ( - - ) : ( - - )} - {cover.name} -
- -
- {cover.position > 0 && ( - - {cover.position}% + {data.lights.length > 0 ? ( +
+ {data.lights.map((light) => { + const isOn = light.state === "on"; + const isPending = pending.has(light.entity_id); + return ( + + ); + })} +
+ ) : ( +

Keine Lichter

+ )} +
+ + {/* Switches */} + {data.switches.length > 0 && ( +
+
+
+ + Schalter +
+ + {data.switches_on}/{data.switches_total} + +
+ +
+ {data.switches.map((sw) => { + const isOn = sw.state === "on"; + const isPending = pending.has(sw.entity_id); + return ( + + ); + })} +
+
+ )} +
+ + {/* ── Right Column: Sensors + Covers + Binary + Climate ── */} +
+ {/* Temperature Sensors */} + {tempSensors.length > 0 && ( +
+
+ + Temperaturen +
+
+ {tempSensors.map((sensor) => ( +
+ {sensor.name} + + {typeof sensor.state === "number" ? sensor.state.toFixed(1) : sensor.state} + {sensor.unit} + +
+ ))} +
+
+ )} + + {/* Humidity Sensors */} + {humiditySensors.length > 0 && ( +
+
+ + Luftfeuchte +
+
+ {humiditySensors.map((sensor) => ( +
+ {sensor.name} + + {typeof sensor.state === "number" ? sensor.state.toFixed(1) : sensor.state} + {sensor.unit} + +
+ ))} +
+
+ )} + + {/* Climate */} + {data.climate.length > 0 && ( +
+
+ + Klima +
+
+ {data.climate.map((cl) => ( +
+
+ {cl.name} + + {cl.state === "heat" ? "Heizen" : cl.state === "cool" ? "Kühlen" : cl.state === "off" ? "Aus" : cl.state}
+
+ {cl.current_temperature != null && ( + + {cl.current_temperature.toFixed(1)} + °C + + )} + {cl.target_temperature != null && ( + + Ziel: {cl.target_temperature.toFixed(1)}°C + + )} +
- ); - })} -
-
- )} + ))} +
+ + )} - {/* Temperature Sensors */} - {data.sensors.length > 0 && ( -
-
- - Temperaturen -
+ {/* Covers */} + {data.covers.length > 0 && ( +
+
+ + Rollos +
+
+ {data.covers.map((cover) => { + const isOpen = cover.state === "open"; + const isClosed = cover.state === "closed"; + const isPending = pending.has(cover.entity_id); + return ( +
+
+ {isOpen ? ( + + ) : isClosed ? ( + + ) : ( + + )} + {cover.name} +
-
- {data.sensors.map((sensor) => ( -
- {sensor.name} - - {typeof sensor.state === "number" - ? sensor.state.toFixed(1) - : sensor.state} - {sensor.unit} - -
- ))} -
-
- )} +
+ {cover.current_position != null && cover.current_position > 0 && ( + + {cover.current_position}% + + )} + + + +
+
+ ); + })} +
+ + )} + + {/* Binary Sensors */} + {data.binary_sensors.length > 0 && ( +
+
+ + Sensoren +
+
+ {data.binary_sensors.map((bs) => { + const isActive = bs.state === "on"; + const Icon = + bs.device_class === "door" + ? isActive + ? DoorOpen + : DoorClosed + : bs.device_class === "window" + ? isActive + ? DoorOpen + : DoorClosed + : Eye; + return ( +
+ + + {bs.name} + +
+
+ ); + })} +
+
+ )} +
); diff --git a/web/src/mockData.ts b/web/src/mockData.ts index 8478b19..21ab9d4 100644 --- a/web/src/mockData.ts +++ b/web/src/mockData.ts @@ -133,18 +133,34 @@ export const MOCK_DATA: DashboardData = { { entity_id: "light.balkon", name: "Balkon", state: "off", brightness: 0 }, { entity_id: "light.gaestezimmer", name: "Gästezimmer", state: "off", brightness: 0 }, ], + switches: [ + { entity_id: "switch.buro_pc", name: "Büro PC", state: "on" }, + { entity_id: "switch.teich", name: "Teich Socket", state: "on" }, + { entity_id: "switch.kinderzimmer", name: "Kinderzimmer Plug", state: "off" }, + ], covers: [ - { entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", position: 100 }, - { entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", position: 0 }, - { entity_id: "cover.kueche", name: "Küche Rollo", state: "open", position: 75 }, + { entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", current_position: 100, device_class: "shutter" }, + { entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", current_position: 0, device_class: "shutter" }, + { entity_id: "cover.kueche", name: "Küche Rollo", state: "open", current_position: 75, device_class: "curtain" }, ], sensors: [ - { entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, unit: "°C" }, - { entity_id: "sensor.temp_aussen", name: "Außen", state: 7.8, unit: "°C" }, - { entity_id: "sensor.temp_schlafzimmer", name: "Schlafzimmer", state: 19.2, unit: "°C" }, + { entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, unit: "°C", device_class: "temperature" }, + { entity_id: "sensor.temp_aussen", name: "Außen", state: 7.8, unit: "°C", device_class: "temperature" }, + { entity_id: "sensor.temp_schlafzimmer", name: "Schlafzimmer", state: 19.2, unit: "°C", device_class: "temperature" }, + { entity_id: "sensor.hum_wohnzimmer", name: "Wohnzimmer", state: 48.2, unit: "%", device_class: "humidity" }, + { entity_id: "sensor.hum_kueche", name: "Küche", state: 33.4, unit: "%", device_class: "humidity" }, + ], + binary_sensors: [ + { entity_id: "binary_sensor.front_door", name: "Haustür", state: "off", device_class: "door" }, + { entity_id: "binary_sensor.motion_og", name: "Bewegung OG", state: "off", device_class: "occupancy" }, + ], + climate: [ + { entity_id: "climate.klima", name: "Klima", state: "heat", current_temperature: 23, target_temperature: 25, hvac_modes: ["off", "heat", "cool"], humidity: 28 }, ], lights_on: 3, lights_total: 8, + switches_on: 2, + switches_total: 3, }, mqtt: { connected: true, diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index a93e113..e4723c3 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -95,7 +95,9 @@ export default function Dashboard() { {data.servers.servers.map((srv) => ( ))} - +
+ +