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 from __future__ import annotations
import logging 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.cache import cache
from server.config import get_settings 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__) logger = logging.getLogger(__name__)
@ -18,22 +20,18 @@ router = APIRouter(prefix="/api", tags=["homeassistant"])
CACHE_KEY = "ha" CACHE_KEY = "ha"
# ---------------------------------------------------------------------------
# GET /api/ha — read-only, no auth needed
# ---------------------------------------------------------------------------
@router.get("/ha") @router.get("/ha")
async def get_ha() -> Dict[str, Any]: 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) cached = await cache.get(CACHE_KEY)
if cached is not None: if cached is not None:
return cached return cached
# --- cache miss -----------------------------------------------------------
try: try:
data: Dict[str, Any] = await fetch_ha_data( data: Dict[str, Any] = await fetch_ha_data(
get_settings().ha_url, 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) await cache.set(CACHE_KEY, data, get_settings().ha_cache_ttl)
return data 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 from __future__ import annotations
import httpx import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import httpx
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _friendly_name(entity: Dict[str, Any]) -> str: def _friendly_name(entity: Dict[str, Any]) -> str:
"""Extract the friendly name from an entity's attributes, falling back to entity_id.""" """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]: def _parse_light(entity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a light entity into a normalised dictionary."""
attrs = entity.get("attributes", {}) attrs = entity.get("attributes", {})
state = entity.get("state", "unknown")
brightness_raw = attrs.get("brightness") brightness_raw = attrs.get("brightness")
brightness_pct: Optional[int] = None brightness_pct: Optional[int] = None
if brightness_raw is not 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) brightness_pct = round(int(brightness_raw) / 255 * 100)
except (ValueError, TypeError): except (ValueError, TypeError):
brightness_pct = None brightness_pct = None
return { return {
"entity_id": entity.get("entity_id", ""), "entity_id": entity.get("entity_id", ""),
"name": _friendly_name(entity), "name": _friendly_name(entity),
"state": state, "state": entity.get("state", "unknown"),
"brightness": brightness_pct, "brightness": brightness_pct,
"color_mode": attrs.get("color_mode"), "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]: def _parse_cover(entity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a cover entity into a normalised dictionary."""
attrs = entity.get("attributes", {}) attrs = entity.get("attributes", {})
return { return {
"entity_id": entity.get("entity_id", ""), "entity_id": entity.get("entity_id", ""),
"name": _friendly_name(entity), "name": _friendly_name(entity),
"state": entity.get("state", "unknown"), "state": entity.get("state", "unknown"),
"current_position": attrs.get("current_position"), "current_position": attrs.get("current_position"),
"device_class": attrs.get("device_class", ""),
} }
def _parse_sensor(entity: Dict[str, Any]) -> Dict[str, Any]: def _parse_sensor(entity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse a temperature sensor entity into a normalised dictionary."""
attrs = entity.get("attributes", {}) attrs = entity.get("attributes", {})
state_value = entity.get("state", "unknown") state_value = entity.get("state", "unknown")
try: try:
state_value = round(float(state_value), 1) state_value = round(float(state_value), 1)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
return { return {
"entity_id": entity.get("entity_id", ""), "entity_id": entity.get("entity_id", ""),
"name": _friendly_name(entity), "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]: 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] = { result: Dict[str, Any] = {
"online": False, "online": False,
"lights": [], "lights": [],
"switches": [],
"covers": [], "covers": [],
"sensors": [], "sensors": [],
"binary_sensors": [],
"climate": [],
"lights_on": 0, "lights_on": 0,
"lights_total": 0, "lights_total": 0,
"switches_on": 0,
"switches_total": 0,
"error": None, "error": None,
} }
@ -115,8 +163,11 @@ async def fetch_ha_data(url: str, token: str) -> Dict[str, Any]:
result["online"] = True result["online"] = True
lights: List[Dict[str, Any]] = [] lights: List[Dict[str, Any]] = []
switches: List[Dict[str, Any]] = []
covers: List[Dict[str, Any]] = [] covers: List[Dict[str, Any]] = []
sensors: 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: for entity in entities:
entity_id: str = entity.get("entity_id", "") 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": if domain == "light":
lights.append(_parse_light(entity)) lights.append(_parse_light(entity))
elif domain == "switch":
switches.append(_parse_switch(entity))
elif domain == "cover": elif domain == "cover":
covers.append(_parse_cover(entity)) covers.append(_parse_cover(entity))
elif domain == "sensor": elif domain == "sensor":
device_class = attrs.get("device_class", "") device_class = attrs.get("device_class", "")
if device_class == "temperature": if device_class in _SENSOR_CLASSES:
sensors.append(_parse_sensor(entity)) 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["lights"] = lights
result["switches"] = switches
result["covers"] = covers result["covers"] = covers
result["sensors"] = sensors 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["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 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[]; 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 { export interface HAData {
online: boolean; online: boolean;
lights: { entity_id: string; name: string; state: string; brightness: number }[]; lights: HALight[];
covers: { entity_id: string; name: string; state: string; position: number }[]; switches: HASwitch[];
sensors: { entity_id: string; name: string; state: number; unit: string }[]; covers: HACover[];
sensors: HASensor[];
binary_sensors: HABinarySensor[];
climate: HAClimate[];
lights_on: number; lights_on: number;
lights_total: number; lights_total: number;
switches_on: number;
switches_total: number;
error?: boolean; error?: boolean;
} }
@ -130,6 +182,22 @@ export const fetchNews = (limit = 20, offset = 0, category?: string) => {
}; };
export const fetchServers = () => fetchJSON<ServersResponse>("/servers"); export const fetchServers = () => fetchJSON<ServersResponse>("/servers");
export const fetchHA = () => fetchJSON<HAData>("/ha"); 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 fetchTasks = () => fetchJSON<TasksResponse>("/tasks");
export const fetchMqtt = () => fetchJSON<MqttData>("/mqtt"); export const fetchMqtt = () => fetchJSON<MqttData>("/mqtt");
export const fetchAll = () => fetchJSON<DashboardData>("/all"); 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 type { HAData } from "../api";
import { controlHA } from "../api";
/* ── Types ───────────────────────────────────────────────── */
interface HomeAssistantProps { interface HomeAssistantProps {
data: HAData; data: HAData;
} }
/* ── Main Component ──────────────────────────────────────── */
export default function HomeAssistant({ data }: HomeAssistantProps) { 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) return null;
if (data.error) { if (data.error) {
return ( 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"> <div className="flex items-center gap-3 text-cherry">
<WifiOff className="w-5 h-5" /> <WifiOff className="w-5 h-5" />
<div> <div>
@ -22,23 +68,36 @@ 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 ( 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 */} {/* Header */}
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Home className="w-4 h-4 text-iris" /> <Home className="w-4 h-4 text-iris" />
<h3 className="text-sm font-semibold text-base-900">Home Assistant</h3> <h3 className="text-sm font-semibold text-base-900">Home Assistant</h3>
</div> </div>
<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="flex items-center gap-2">
<div className={`status-dot ${data.online ? "bg-mint" : "bg-cherry"}`} /> <div className={`status-dot ${data.online ? "bg-mint" : "bg-cherry"}`} />
<span className={`text-[10px] font-mono font-medium ${data.online ? "text-mint" : "text-cherry"}`}> <span
className={`text-[10px] font-mono font-medium ${data.online ? "text-mint" : "text-cherry"}`}
>
{data.online ? "Online" : "Offline"} {data.online ? "Online" : "Offline"}
</span> </span>
</div> </div>
</div> </div>
</div>
{/* Two-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* ── Left Column: Lights + Switches ────────────── */}
<div className="space-y-5"> <div className="space-y-5">
{/* Lights */} {/* Lights */}
<section> <section>
@ -47,47 +106,189 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
<Lightbulb className="w-3 h-3 text-base-500" /> <Lightbulb className="w-3 h-3 text-base-500" />
<span className="data-label">Lichter</span> <span className="data-label">Lichter</span>
</div> </div>
<span className="tag border-iris/30 text-iris bg-iris/5"> <span className="tag border-gold/30 text-gold bg-gold/5">
{data.lights_on}/{data.lights_total} {data.lights_on}/{data.lights_total}
</span> </span>
</div> </div>
{data.lights.length > 0 ? ( {data.lights.length > 0 ? (
<div className="grid grid-cols-4 gap-px bg-base-300"> <div className="grid grid-cols-3 gap-px bg-base-300">
{data.lights.map((light) => { {data.lights.map((light) => {
const isOn = light.state === "on"; const isOn = light.state === "on";
const isPending = pending.has(light.entity_id);
return ( return (
<div <button
key={light.entity_id} key={light.entity_id}
className={`flex flex-col items-center gap-1.5 px-2 py-3 transition-colors ${ onClick={() => handleControl(light.entity_id, "toggle")}
isOn ? "bg-gold/[0.08]" : "bg-base-50" 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 <div
className={`w-2 h-2 transition-all ${ className={`w-2.5 h-2.5 transition-all ${
isOn isOn
? "bg-gold shadow-[0_0_8px_#e8a44a80]" ? "bg-gold shadow-[0_0_10px_#e8a44a80]"
: "bg-base-400" : "bg-base-400"
}`} }`}
style={{ borderRadius: 0 }}
/> />
<span className="text-[9px] text-center text-base-600 leading-tight truncate w-full font-mono"> <span className="text-[9px] text-center text-base-600 leading-tight truncate w-full font-mono">
{light.name} {light.name}
</span> </span>
{isOn && light.brightness > 0 && ( {isOn && light.brightness != null && light.brightness > 0 && (
<span className="text-[9px] font-mono text-gold/70"> <span className="text-[9px] font-mono text-gold/70">
{Math.round((light.brightness / 255) * 100)}% {light.brightness}%
</span> </span>
)} )}
</div> </button>
); );
})} })}
</div> </div>
) : ( ) : (
<p className="text-xs text-base-500">Keine Lichter konfiguriert</p> <p className="text-xs text-base-500 italic">Keine Lichter</p>
)} )}
</section> </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 flex-shrink-0 ${
isOn ? "text-mint" : "text-base-500"
}`}
>
{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>
)}
{/* Covers */} {/* Covers */}
{data.covers.length > 0 && ( {data.covers.length > 0 && (
<section> <section>
@ -95,40 +296,59 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
<ArrowUp className="w-3 h-3 text-base-500" /> <ArrowUp className="w-3 h-3 text-base-500" />
<span className="data-label">Rollos</span> <span className="data-label">Rollos</span>
</div> </div>
<div className="space-y-px"> <div className="space-y-px">
{data.covers.map((cover) => { {data.covers.map((cover) => {
const isOpen = cover.state === "open"; const isOpen = cover.state === "open";
const isClosed = cover.state === "closed"; const isClosed = cover.state === "closed";
const isPending = pending.has(cover.entity_id);
return ( return (
<div <div
key={cover.entity_id} 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" 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"> <div className="flex items-center gap-2 min-w-0">
{isOpen ? ( {isOpen ? (
<ArrowUp className="w-3 h-3 text-mint" /> <ArrowUp className="w-3 h-3 text-mint flex-shrink-0" />
) : isClosed ? ( ) : isClosed ? (
<ArrowDown className="w-3 h-3 text-base-500" /> <ArrowDown className="w-3 h-3 text-base-500 flex-shrink-0" />
) : ( ) : (
<ArrowDown className="w-3 h-3 text-gold" /> <ArrowDown className="w-3 h-3 text-gold flex-shrink-0" />
)} )}
<span className="text-xs text-base-700">{cover.name}</span> <span className="text-xs text-base-700 truncate">{cover.name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1 flex-shrink-0">
{cover.position > 0 && ( {cover.current_position != null && cover.current_position > 0 && (
<span className="text-[10px] font-mono text-base-500"> <span className="text-[10px] font-mono text-base-500 mr-1">
{cover.position}% {cover.current_position}%
</span> </span>
)} )}
<span <button
className={`text-[10px] font-mono font-medium ${ onClick={() => handleControl(cover.entity_id, "open")}
isOpen ? "text-mint" : isClosed ? "text-base-500" : "text-gold" 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"
> >
{isOpen ? "Offen" : isClosed ? "Zu" : cover.state} <ArrowUp className="w-3 h-3" />
</span> </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> </div>
); );
@ -137,33 +357,54 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
</section> </section>
)} )}
{/* Temperature Sensors */} {/* Binary Sensors */}
{data.sensors.length > 0 && ( {data.binary_sensors.length > 0 && (
<section> <section>
<div className="flex items-center gap-1.5 mb-3"> <div className="flex items-center gap-1.5 mb-3">
<Thermometer className="w-3 h-3 text-base-500" /> <Eye className="w-3 h-3 text-base-500" />
<span className="data-label">Temperaturen</span> <span className="data-label">Sensoren</span>
</div> </div>
<div className="grid grid-cols-2 gap-px bg-base-300">
<div className="space-y-px"> {data.binary_sensors.map((bs) => {
{data.sensors.map((sensor) => ( const isActive = bs.state === "on";
const Icon =
bs.device_class === "door"
? isActive
? DoorOpen
: DoorClosed
: bs.device_class === "window"
? isActive
? DoorOpen
: DoorClosed
: Eye;
return (
<div <div
key={sensor.entity_id} key={bs.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" className={`flex items-center gap-2 px-3 py-2 ${
isActive ? "bg-gold/[0.06]" : "bg-base-50"
}`}
> >
<span className="text-xs text-base-700">{sensor.name}</span> <Icon
<span className="text-sm data-value text-base-900"> className={`w-3 h-3 flex-shrink-0 ${
{typeof sensor.state === "number" isActive ? "text-gold" : "text-base-500"
? sensor.state.toFixed(1) }`}
: sensor.state} />
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span> <span className="text-[10px] text-base-600 truncate font-mono">
{bs.name}
</span> </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>
))} );
})}
</div> </div>
</section> </section>
)} )}
</div> </div>
</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.balkon", name: "Balkon", state: "off", brightness: 0 },
{ entity_id: "light.gaestezimmer", name: "Gästezimmer", 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: [ covers: [
{ entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", position: 100 }, { entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", current_position: 100, device_class: "shutter" },
{ entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", position: 0 }, { 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", position: 75 }, { entity_id: "cover.kueche", name: "Küche Rollo", state: "open", current_position: 75, device_class: "curtain" },
], ],
sensors: [ sensors: [
{ entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, 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" }, { 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" }, { 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_on: 3,
lights_total: 8, lights_total: 8,
switches_on: 2,
switches_total: 3,
}, },
mqtt: { mqtt: {
connected: true, connected: true,

View file

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