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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue