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 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 19:30:20 +01:00
parent 4e7c1909ee
commit f5b7c53f18
6 changed files with 683 additions and 168 deletions

View file

@ -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

View file

@ -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}

View file

@ -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<ServersResponse>("/servers");
export const fetchHA = () => fetchJSON<HAData>("/ha");
export const controlHA = async (
entity_id: string,
action: string,
data?: Record<string, unknown>,
): 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<TasksResponse>("/tasks");
export const fetchMqtt = () => fetchJSON<MqttData>("/mqtt");
export const fetchAll = () => fetchJSON<DashboardData>("/all");

View file

@ -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<Set<string>>(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 (
<div className="deck-card p-5 animate-fade-in">
<div className="deck-card p-5 animate-fade-in h-full">
<div className="flex items-center gap-3 text-cherry">
<WifiOff className="w-5 h-5" />
<div>
@ -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 (
<div className="deck-card p-5 animate-fade-in" data-accent="iris">
<div className="deck-card p-5 animate-fade-in h-full" data-accent="iris">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<Home className="w-4 h-4 text-iris" />
<h3 className="text-sm font-semibold text-base-900">Home Assistant</h3>
</div>
<div className="flex items-center gap-2">
<div className={`status-dot ${data.online ? "bg-mint" : "bg-cherry"}`} />
<span className={`text-[10px] font-mono font-medium ${data.online ? "text-mint" : "text-cherry"}`}>
{data.online ? "Online" : "Offline"}
<div className="flex items-center gap-3">
<span className="tag border-iris/30 text-iris bg-iris/5">
{data.lights_on + data.switches_on} aktiv
</span>
<div className="flex items-center gap-2">
<div className={`status-dot ${data.online ? "bg-mint" : "bg-cherry"}`} />
<span
className={`text-[10px] font-mono font-medium ${data.online ? "text-mint" : "text-cherry"}`}
>
{data.online ? "Online" : "Offline"}
</span>
</div>
</div>
</div>
<div className="space-y-5">
{/* Lights */}
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Lightbulb className="w-3 h-3 text-base-500" />
<span className="data-label">Lichter</span>
</div>
<span className="tag border-iris/30 text-iris bg-iris/5">
{data.lights_on}/{data.lights_total}
</span>
</div>
{data.lights.length > 0 ? (
<div className="grid grid-cols-4 gap-px bg-base-300">
{data.lights.map((light) => {
const isOn = light.state === "on";
return (
<div
key={light.entity_id}
className={`flex flex-col items-center gap-1.5 px-2 py-3 transition-colors ${
isOn ? "bg-gold/[0.08]" : "bg-base-50"
}`}
>
<div
className={`w-2 h-2 transition-all ${
isOn
? "bg-gold shadow-[0_0_8px_#e8a44a80]"
: "bg-base-400"
}`}
style={{ borderRadius: 0 }}
/>
<span className="text-[9px] text-center text-base-600 leading-tight truncate w-full font-mono">
{light.name}
</span>
{isOn && light.brightness > 0 && (
<span className="text-[9px] font-mono text-gold/70">
{Math.round((light.brightness / 255) * 100)}%
</span>
)}
</div>
);
})}
</div>
) : (
<p className="text-xs text-base-500">Keine Lichter konfiguriert</p>
)}
</section>
{/* Covers */}
{data.covers.length > 0 && (
{/* Two-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* ── Left Column: Lights + Switches ────────────── */}
<div className="space-y-5">
{/* Lights */}
<section>
<div className="flex items-center gap-1.5 mb-3">
<ArrowUp className="w-3 h-3 text-base-500" />
<span className="data-label">Rollos</span>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Lightbulb className="w-3 h-3 text-base-500" />
<span className="data-label">Lichter</span>
</div>
<span className="tag border-gold/30 text-gold bg-gold/5">
{data.lights_on}/{data.lights_total}
</span>
</div>
<div className="space-y-px">
{data.covers.map((cover) => {
const isOpen = cover.state === "open";
const isClosed = cover.state === "closed";
return (
<div
key={cover.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
>
<div className="flex items-center gap-2">
{isOpen ? (
<ArrowUp className="w-3 h-3 text-mint" />
) : isClosed ? (
<ArrowDown className="w-3 h-3 text-base-500" />
) : (
<ArrowDown className="w-3 h-3 text-gold" />
)}
<span className="text-xs text-base-700">{cover.name}</span>
</div>
<div className="flex items-center gap-2">
{cover.position > 0 && (
<span className="text-[10px] font-mono text-base-500">
{cover.position}%
{data.lights.length > 0 ? (
<div className="grid grid-cols-3 gap-px bg-base-300">
{data.lights.map((light) => {
const isOn = light.state === "on";
const isPending = pending.has(light.entity_id);
return (
<button
key={light.entity_id}
onClick={() => handleControl(light.entity_id, "toggle")}
disabled={isPending}
className={`flex flex-col items-center gap-1.5 px-2 py-3 transition-all cursor-pointer
${isOn ? "bg-gold/[0.08] hover:bg-gold/[0.15]" : "bg-base-50 hover:bg-base-100"}
${isPending ? "opacity-50 animate-pulse" : ""}`}
title={`${light.name} ${isOn ? "ausschalten" : "einschalten"}`}
>
<div
className={`w-2.5 h-2.5 transition-all ${
isOn
? "bg-gold shadow-[0_0_10px_#e8a44a80]"
: "bg-base-400"
}`}
/>
<span className="text-[9px] text-center text-base-600 leading-tight truncate w-full font-mono">
{light.name}
</span>
{isOn && light.brightness != null && light.brightness > 0 && (
<span className="text-[9px] font-mono text-gold/70">
{light.brightness}%
</span>
)}
</button>
);
})}
</div>
) : (
<p className="text-xs text-base-500 italic">Keine Lichter</p>
)}
</section>
{/* Switches */}
{data.switches.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Power className="w-3 h-3 text-base-500" />
<span className="data-label">Schalter</span>
</div>
<span className="tag border-mint/30 text-mint bg-mint/5">
{data.switches_on}/{data.switches_total}
</span>
</div>
<div className="space-y-px">
{data.switches.map((sw) => {
const isOn = sw.state === "on";
const isPending = pending.has(sw.entity_id);
return (
<button
key={sw.entity_id}
onClick={() => handleControl(sw.entity_id, "toggle")}
disabled={isPending}
className={`flex items-center justify-between w-full px-3 py-2 transition-all cursor-pointer
border-l-2 ${isOn ? "border-mint bg-mint/[0.05] hover:bg-mint/[0.10]" : "border-base-300 bg-base-100 hover:bg-base-200"}
${isPending ? "opacity-50 animate-pulse" : ""}`}
title={`${sw.name} ${isOn ? "ausschalten" : "einschalten"}`}
>
<span className="text-xs text-base-700 truncate mr-2">{sw.name}</span>
<span
className={`text-[10px] font-mono font-medium ${
isOpen ? "text-mint" : isClosed ? "text-base-500" : "text-gold"
className={`text-[10px] font-mono font-medium flex-shrink-0 ${
isOn ? "text-mint" : "text-base-500"
}`}
>
{isOpen ? "Offen" : isClosed ? "Zu" : cover.state}
{isOn ? "AN" : "AUS"}
</span>
</button>
);
})}
</div>
</section>
)}
</div>
{/* ── Right Column: Sensors + Covers + Binary + Climate ── */}
<div className="space-y-5">
{/* Temperature Sensors */}
{tempSensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Thermometer className="w-3 h-3 text-base-500" />
<span className="data-label">Temperaturen</span>
</div>
<div className="space-y-px">
{tempSensors.map((sensor) => (
<div
key={sensor.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
>
<span className="text-xs text-base-700 truncate mr-2">{sensor.name}</span>
<span className="text-sm data-value text-base-900 flex-shrink-0">
{typeof sensor.state === "number" ? sensor.state.toFixed(1) : sensor.state}
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span>
</span>
</div>
))}
</div>
</section>
)}
{/* Humidity Sensors */}
{humiditySensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Droplets className="w-3 h-3 text-base-500" />
<span className="data-label">Luftfeuchte</span>
</div>
<div className="space-y-px">
{humiditySensors.map((sensor) => (
<div
key={sensor.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-azure transition-colors"
>
<span className="text-xs text-base-700 truncate mr-2">{sensor.name}</span>
<span className="text-sm data-value text-base-900 flex-shrink-0">
{typeof sensor.state === "number" ? sensor.state.toFixed(1) : sensor.state}
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span>
</span>
</div>
))}
</div>
</section>
)}
{/* Climate */}
{data.climate.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Flame className="w-3 h-3 text-base-500" />
<span className="data-label">Klima</span>
</div>
<div className="space-y-px">
{data.climate.map((cl) => (
<div
key={cl.entity_id}
className="px-3 py-2 bg-base-100 border-l-2 border-cherry/40"
>
<div className="flex items-center justify-between">
<span className="text-xs text-base-700">{cl.name}</span>
<span
className={`text-[10px] font-mono font-medium ${
cl.state === "heat"
? "text-cherry"
: cl.state === "cool"
? "text-azure"
: "text-base-500"
}`}
>
{cl.state === "heat" ? "Heizen" : cl.state === "cool" ? "Kühlen" : cl.state === "off" ? "Aus" : cl.state}
</span>
</div>
<div className="flex items-center gap-4 mt-1">
{cl.current_temperature != null && (
<span className="text-sm data-value text-base-900">
{cl.current_temperature.toFixed(1)}
<span className="text-[10px] text-base-500 ml-0.5">°C</span>
</span>
)}
{cl.target_temperature != null && (
<span className="text-[10px] font-mono text-base-500">
Ziel: {cl.target_temperature.toFixed(1)}°C
</span>
)}
</div>
</div>
);
})}
</div>
</section>
)}
))}
</div>
</section>
)}
{/* Temperature Sensors */}
{data.sensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Thermometer className="w-3 h-3 text-base-500" />
<span className="data-label">Temperaturen</span>
</div>
{/* Covers */}
{data.covers.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<ArrowUp className="w-3 h-3 text-base-500" />
<span className="data-label">Rollos</span>
</div>
<div className="space-y-px">
{data.covers.map((cover) => {
const isOpen = cover.state === "open";
const isClosed = cover.state === "closed";
const isPending = pending.has(cover.entity_id);
return (
<div
key={cover.entity_id}
className={`flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 transition-colors
${isOpen ? "border-mint" : isClosed ? "border-base-300" : "border-gold"}
${isPending ? "opacity-50 animate-pulse" : ""}`}
>
<div className="flex items-center gap-2 min-w-0">
{isOpen ? (
<ArrowUp className="w-3 h-3 text-mint flex-shrink-0" />
) : isClosed ? (
<ArrowDown className="w-3 h-3 text-base-500 flex-shrink-0" />
) : (
<ArrowDown className="w-3 h-3 text-gold flex-shrink-0" />
)}
<span className="text-xs text-base-700 truncate">{cover.name}</span>
</div>
<div className="space-y-px">
{data.sensors.map((sensor) => (
<div
key={sensor.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
>
<span className="text-xs text-base-700">{sensor.name}</span>
<span className="text-sm data-value text-base-900">
{typeof sensor.state === "number"
? sensor.state.toFixed(1)
: sensor.state}
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span>
</span>
</div>
))}
</div>
</section>
)}
<div className="flex items-center gap-1 flex-shrink-0">
{cover.current_position != null && cover.current_position > 0 && (
<span className="text-[10px] font-mono text-base-500 mr-1">
{cover.current_position}%
</span>
)}
<button
onClick={() => handleControl(cover.entity_id, "open")}
disabled={isPending}
className="w-6 h-6 flex items-center justify-center bg-base-200 hover:bg-mint/20 hover:text-mint transition-colors"
title="Öffnen"
>
<ArrowUp className="w-3 h-3" />
</button>
<button
onClick={() => handleControl(cover.entity_id, "stop")}
disabled={isPending}
className="w-6 h-6 flex items-center justify-center bg-base-200 hover:bg-gold/20 hover:text-gold transition-colors"
title="Stopp"
>
<Square className="w-2.5 h-2.5" />
</button>
<button
onClick={() => handleControl(cover.entity_id, "close")}
disabled={isPending}
className="w-6 h-6 flex items-center justify-center bg-base-200 hover:bg-cherry/20 hover:text-cherry transition-colors"
title="Schließen"
>
<ArrowDown className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
</section>
)}
{/* Binary Sensors */}
{data.binary_sensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Eye className="w-3 h-3 text-base-500" />
<span className="data-label">Sensoren</span>
</div>
<div className="grid grid-cols-2 gap-px bg-base-300">
{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 (
<div
key={bs.entity_id}
className={`flex items-center gap-2 px-3 py-2 ${
isActive ? "bg-gold/[0.06]" : "bg-base-50"
}`}
>
<Icon
className={`w-3 h-3 flex-shrink-0 ${
isActive ? "text-gold" : "text-base-500"
}`}
/>
<span className="text-[10px] text-base-600 truncate font-mono">
{bs.name}
</span>
<div
className={`w-1.5 h-1.5 flex-shrink-0 ml-auto ${
isActive ? "bg-gold shadow-[0_0_6px_#e8a44a80]" : "bg-base-400"
}`}
/>
</div>
);
})}
</div>
</section>
)}
</div>
</div>
</div>
);

View file

@ -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,

View file

@ -95,7 +95,9 @@ export default function Dashboard() {
{data.servers.servers.map((srv) => (
<ServerCard key={srv.name} server={srv} />
))}
<HomeAssistant data={data.ha} />
<div className="xl:col-span-2">
<HomeAssistant data={data.ha} />
</div>
<TasksCard data={data.tasks} />
</div>
</section>