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:
parent
4e7c1909ee
commit
f5b7c53f18
6 changed files with 683 additions and 168 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue