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

@ -66,13 +66,65 @@ export interface ServersResponse {
servers: ServerStats[];
}
export interface HALight {
entity_id: string;
name: string;
state: string;
brightness: number | null;
color_mode?: string;
}
export interface HASwitch {
entity_id: string;
name: string;
state: string;
}
export interface HACover {
entity_id: string;
name: string;
state: string;
current_position: number | null;
device_class?: string;
}
export interface HASensor {
entity_id: string;
name: string;
state: number | string;
unit: string;
device_class?: string;
}
export interface HABinarySensor {
entity_id: string;
name: string;
state: string;
device_class: string;
}
export interface HAClimate {
entity_id: string;
name: string;
state: string;
current_temperature: number | null;
target_temperature: number | null;
hvac_modes: string[];
humidity: number | null;
}
export interface HAData {
online: boolean;
lights: { entity_id: string; name: string; state: string; brightness: number }[];
covers: { entity_id: string; name: string; state: string; position: number }[];
sensors: { entity_id: string; name: string; state: number; unit: string }[];
lights: HALight[];
switches: HASwitch[];
covers: HACover[];
sensors: HASensor[];
binary_sensors: HABinarySensor[];
climate: HAClimate[];
lights_on: number;
lights_total: number;
switches_on: number;
switches_total: number;
error?: boolean;
}
@ -130,6 +182,22 @@ export const fetchNews = (limit = 20, offset = 0, category?: string) => {
};
export const fetchServers = () => fetchJSON<ServersResponse>("/servers");
export const fetchHA = () => fetchJSON<HAData>("/ha");
export const controlHA = async (
entity_id: string,
action: string,
data?: Record<string, unknown>,
): Promise<{ ok: boolean; error?: string }> => {
const token = localStorage.getItem("token");
const res = await fetch(`${API_BASE}/ha/control`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ entity_id, action, data }),
});
return res.json();
};
export const fetchTasks = () => fetchJSON<TasksResponse>("/tasks");
export const fetchMqtt = () => fetchJSON<MqttData>("/mqtt");
export const fetchAll = () => fetchJSON<DashboardData>("/all");

View file

@ -1,16 +1,62 @@
import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff } from "lucide-react";
import { useState, useCallback } from "react";
import {
Home,
Lightbulb,
Power,
ArrowUp,
ArrowDown,
Square,
Thermometer,
Droplets,
DoorOpen,
DoorClosed,
Eye,
Flame,
WifiOff,
} from "lucide-react";
import type { HAData } from "../api";
import { controlHA } from "../api";
/* ── Types ───────────────────────────────────────────────── */
interface HomeAssistantProps {
data: HAData;
}
/* ── Main Component ──────────────────────────────────────── */
export default function HomeAssistant({ data }: HomeAssistantProps) {
const [pending, setPending] = useState<Set<string>>(new Set());
const handleControl = useCallback(
async (entity_id: string, action: string) => {
setPending((p) => new Set(p).add(entity_id));
try {
const result = await controlHA(entity_id, action);
if (!result.ok) {
console.error(`[HA] Control failed: ${result.error}`);
}
} catch (err) {
console.error("[HA] Control error:", err);
} finally {
// Small delay so HA can process, then let parent refresh
setTimeout(() => {
setPending((p) => {
const next = new Set(p);
next.delete(entity_id);
return next;
});
}, 600);
}
},
[],
);
if (!data) return null;
if (data.error) {
return (
<div className="deck-card p-5 animate-fade-in">
<div className="deck-card p-5 animate-fade-in h-full">
<div className="flex items-center gap-3 text-cherry">
<WifiOff className="w-5 h-5" />
<div>
@ -22,147 +68,342 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
);
}
// Split sensors by type
const tempSensors = data.sensors.filter((s) => s.device_class === "temperature");
const humiditySensors = data.sensors.filter((s) => s.device_class === "humidity");
return (
<div className="deck-card p-5 animate-fade-in" data-accent="iris">
<div className="deck-card p-5 animate-fade-in h-full" data-accent="iris">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<Home className="w-4 h-4 text-iris" />
<h3 className="text-sm font-semibold text-base-900">Home Assistant</h3>
</div>
<div className="flex items-center gap-2">
<div className={`status-dot ${data.online ? "bg-mint" : "bg-cherry"}`} />
<span className={`text-[10px] font-mono font-medium ${data.online ? "text-mint" : "text-cherry"}`}>
{data.online ? "Online" : "Offline"}
<div className="flex items-center gap-3">
<span className="tag border-iris/30 text-iris bg-iris/5">
{data.lights_on + data.switches_on} aktiv
</span>
<div className="flex items-center gap-2">
<div className={`status-dot ${data.online ? "bg-mint" : "bg-cherry"}`} />
<span
className={`text-[10px] font-mono font-medium ${data.online ? "text-mint" : "text-cherry"}`}
>
{data.online ? "Online" : "Offline"}
</span>
</div>
</div>
</div>
<div className="space-y-5">
{/* Lights */}
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Lightbulb className="w-3 h-3 text-base-500" />
<span className="data-label">Lichter</span>
</div>
<span className="tag border-iris/30 text-iris bg-iris/5">
{data.lights_on}/{data.lights_total}
</span>
</div>
{data.lights.length > 0 ? (
<div className="grid grid-cols-4 gap-px bg-base-300">
{data.lights.map((light) => {
const isOn = light.state === "on";
return (
<div
key={light.entity_id}
className={`flex flex-col items-center gap-1.5 px-2 py-3 transition-colors ${
isOn ? "bg-gold/[0.08]" : "bg-base-50"
}`}
>
<div
className={`w-2 h-2 transition-all ${
isOn
? "bg-gold shadow-[0_0_8px_#e8a44a80]"
: "bg-base-400"
}`}
style={{ borderRadius: 0 }}
/>
<span className="text-[9px] text-center text-base-600 leading-tight truncate w-full font-mono">
{light.name}
</span>
{isOn && light.brightness > 0 && (
<span className="text-[9px] font-mono text-gold/70">
{Math.round((light.brightness / 255) * 100)}%
</span>
)}
</div>
);
})}
</div>
) : (
<p className="text-xs text-base-500">Keine Lichter konfiguriert</p>
)}
</section>
{/* Covers */}
{data.covers.length > 0 && (
{/* Two-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* ── Left Column: Lights + Switches ────────────── */}
<div className="space-y-5">
{/* Lights */}
<section>
<div className="flex items-center gap-1.5 mb-3">
<ArrowUp className="w-3 h-3 text-base-500" />
<span className="data-label">Rollos</span>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Lightbulb className="w-3 h-3 text-base-500" />
<span className="data-label">Lichter</span>
</div>
<span className="tag border-gold/30 text-gold bg-gold/5">
{data.lights_on}/{data.lights_total}
</span>
</div>
<div className="space-y-px">
{data.covers.map((cover) => {
const isOpen = cover.state === "open";
const isClosed = cover.state === "closed";
return (
<div
key={cover.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
>
<div className="flex items-center gap-2">
{isOpen ? (
<ArrowUp className="w-3 h-3 text-mint" />
) : isClosed ? (
<ArrowDown className="w-3 h-3 text-base-500" />
) : (
<ArrowDown className="w-3 h-3 text-gold" />
)}
<span className="text-xs text-base-700">{cover.name}</span>
</div>
<div className="flex items-center gap-2">
{cover.position > 0 && (
<span className="text-[10px] font-mono text-base-500">
{cover.position}%
{data.lights.length > 0 ? (
<div className="grid grid-cols-3 gap-px bg-base-300">
{data.lights.map((light) => {
const isOn = light.state === "on";
const isPending = pending.has(light.entity_id);
return (
<button
key={light.entity_id}
onClick={() => handleControl(light.entity_id, "toggle")}
disabled={isPending}
className={`flex flex-col items-center gap-1.5 px-2 py-3 transition-all cursor-pointer
${isOn ? "bg-gold/[0.08] hover:bg-gold/[0.15]" : "bg-base-50 hover:bg-base-100"}
${isPending ? "opacity-50 animate-pulse" : ""}`}
title={`${light.name} ${isOn ? "ausschalten" : "einschalten"}`}
>
<div
className={`w-2.5 h-2.5 transition-all ${
isOn
? "bg-gold shadow-[0_0_10px_#e8a44a80]"
: "bg-base-400"
}`}
/>
<span className="text-[9px] text-center text-base-600 leading-tight truncate w-full font-mono">
{light.name}
</span>
{isOn && light.brightness != null && light.brightness > 0 && (
<span className="text-[9px] font-mono text-gold/70">
{light.brightness}%
</span>
)}
</button>
);
})}
</div>
) : (
<p className="text-xs text-base-500 italic">Keine Lichter</p>
)}
</section>
{/* Switches */}
{data.switches.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1.5">
<Power className="w-3 h-3 text-base-500" />
<span className="data-label">Schalter</span>
</div>
<span className="tag border-mint/30 text-mint bg-mint/5">
{data.switches_on}/{data.switches_total}
</span>
</div>
<div className="space-y-px">
{data.switches.map((sw) => {
const isOn = sw.state === "on";
const isPending = pending.has(sw.entity_id);
return (
<button
key={sw.entity_id}
onClick={() => handleControl(sw.entity_id, "toggle")}
disabled={isPending}
className={`flex items-center justify-between w-full px-3 py-2 transition-all cursor-pointer
border-l-2 ${isOn ? "border-mint bg-mint/[0.05] hover:bg-mint/[0.10]" : "border-base-300 bg-base-100 hover:bg-base-200"}
${isPending ? "opacity-50 animate-pulse" : ""}`}
title={`${sw.name} ${isOn ? "ausschalten" : "einschalten"}`}
>
<span className="text-xs text-base-700 truncate mr-2">{sw.name}</span>
<span
className={`text-[10px] font-mono font-medium ${
isOpen ? "text-mint" : isClosed ? "text-base-500" : "text-gold"
className={`text-[10px] font-mono font-medium flex-shrink-0 ${
isOn ? "text-mint" : "text-base-500"
}`}
>
{isOpen ? "Offen" : isClosed ? "Zu" : cover.state}
{isOn ? "AN" : "AUS"}
</span>
</button>
);
})}
</div>
</section>
)}
</div>
{/* ── Right Column: Sensors + Covers + Binary + Climate ── */}
<div className="space-y-5">
{/* Temperature Sensors */}
{tempSensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Thermometer className="w-3 h-3 text-base-500" />
<span className="data-label">Temperaturen</span>
</div>
<div className="space-y-px">
{tempSensors.map((sensor) => (
<div
key={sensor.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
>
<span className="text-xs text-base-700 truncate mr-2">{sensor.name}</span>
<span className="text-sm data-value text-base-900 flex-shrink-0">
{typeof sensor.state === "number" ? sensor.state.toFixed(1) : sensor.state}
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span>
</span>
</div>
))}
</div>
</section>
)}
{/* Humidity Sensors */}
{humiditySensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Droplets className="w-3 h-3 text-base-500" />
<span className="data-label">Luftfeuchte</span>
</div>
<div className="space-y-px">
{humiditySensors.map((sensor) => (
<div
key={sensor.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-azure transition-colors"
>
<span className="text-xs text-base-700 truncate mr-2">{sensor.name}</span>
<span className="text-sm data-value text-base-900 flex-shrink-0">
{typeof sensor.state === "number" ? sensor.state.toFixed(1) : sensor.state}
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span>
</span>
</div>
))}
</div>
</section>
)}
{/* Climate */}
{data.climate.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Flame className="w-3 h-3 text-base-500" />
<span className="data-label">Klima</span>
</div>
<div className="space-y-px">
{data.climate.map((cl) => (
<div
key={cl.entity_id}
className="px-3 py-2 bg-base-100 border-l-2 border-cherry/40"
>
<div className="flex items-center justify-between">
<span className="text-xs text-base-700">{cl.name}</span>
<span
className={`text-[10px] font-mono font-medium ${
cl.state === "heat"
? "text-cherry"
: cl.state === "cool"
? "text-azure"
: "text-base-500"
}`}
>
{cl.state === "heat" ? "Heizen" : cl.state === "cool" ? "Kühlen" : cl.state === "off" ? "Aus" : cl.state}
</span>
</div>
<div className="flex items-center gap-4 mt-1">
{cl.current_temperature != null && (
<span className="text-sm data-value text-base-900">
{cl.current_temperature.toFixed(1)}
<span className="text-[10px] text-base-500 ml-0.5">°C</span>
</span>
)}
{cl.target_temperature != null && (
<span className="text-[10px] font-mono text-base-500">
Ziel: {cl.target_temperature.toFixed(1)}°C
</span>
)}
</div>
</div>
);
})}
</div>
</section>
)}
))}
</div>
</section>
)}
{/* Temperature Sensors */}
{data.sensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Thermometer className="w-3 h-3 text-base-500" />
<span className="data-label">Temperaturen</span>
</div>
{/* Covers */}
{data.covers.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<ArrowUp className="w-3 h-3 text-base-500" />
<span className="data-label">Rollos</span>
</div>
<div className="space-y-px">
{data.covers.map((cover) => {
const isOpen = cover.state === "open";
const isClosed = cover.state === "closed";
const isPending = pending.has(cover.entity_id);
return (
<div
key={cover.entity_id}
className={`flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 transition-colors
${isOpen ? "border-mint" : isClosed ? "border-base-300" : "border-gold"}
${isPending ? "opacity-50 animate-pulse" : ""}`}
>
<div className="flex items-center gap-2 min-w-0">
{isOpen ? (
<ArrowUp className="w-3 h-3 text-mint flex-shrink-0" />
) : isClosed ? (
<ArrowDown className="w-3 h-3 text-base-500 flex-shrink-0" />
) : (
<ArrowDown className="w-3 h-3 text-gold flex-shrink-0" />
)}
<span className="text-xs text-base-700 truncate">{cover.name}</span>
</div>
<div className="space-y-px">
{data.sensors.map((sensor) => (
<div
key={sensor.entity_id}
className="flex items-center justify-between px-3 py-2 bg-base-100 border-l-2 border-base-300 hover:border-iris transition-colors"
>
<span className="text-xs text-base-700">{sensor.name}</span>
<span className="text-sm data-value text-base-900">
{typeof sensor.state === "number"
? sensor.state.toFixed(1)
: sensor.state}
<span className="text-[10px] text-base-500 ml-0.5">{sensor.unit}</span>
</span>
</div>
))}
</div>
</section>
)}
<div className="flex items-center gap-1 flex-shrink-0">
{cover.current_position != null && cover.current_position > 0 && (
<span className="text-[10px] font-mono text-base-500 mr-1">
{cover.current_position}%
</span>
)}
<button
onClick={() => handleControl(cover.entity_id, "open")}
disabled={isPending}
className="w-6 h-6 flex items-center justify-center bg-base-200 hover:bg-mint/20 hover:text-mint transition-colors"
title="Öffnen"
>
<ArrowUp className="w-3 h-3" />
</button>
<button
onClick={() => handleControl(cover.entity_id, "stop")}
disabled={isPending}
className="w-6 h-6 flex items-center justify-center bg-base-200 hover:bg-gold/20 hover:text-gold transition-colors"
title="Stopp"
>
<Square className="w-2.5 h-2.5" />
</button>
<button
onClick={() => handleControl(cover.entity_id, "close")}
disabled={isPending}
className="w-6 h-6 flex items-center justify-center bg-base-200 hover:bg-cherry/20 hover:text-cherry transition-colors"
title="Schließen"
>
<ArrowDown className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
</section>
)}
{/* Binary Sensors */}
{data.binary_sensors.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Eye className="w-3 h-3 text-base-500" />
<span className="data-label">Sensoren</span>
</div>
<div className="grid grid-cols-2 gap-px bg-base-300">
{data.binary_sensors.map((bs) => {
const isActive = bs.state === "on";
const Icon =
bs.device_class === "door"
? isActive
? DoorOpen
: DoorClosed
: bs.device_class === "window"
? isActive
? DoorOpen
: DoorClosed
: Eye;
return (
<div
key={bs.entity_id}
className={`flex items-center gap-2 px-3 py-2 ${
isActive ? "bg-gold/[0.06]" : "bg-base-50"
}`}
>
<Icon
className={`w-3 h-3 flex-shrink-0 ${
isActive ? "text-gold" : "text-base-500"
}`}
/>
<span className="text-[10px] text-base-600 truncate font-mono">
{bs.name}
</span>
<div
className={`w-1.5 h-1.5 flex-shrink-0 ml-auto ${
isActive ? "bg-gold shadow-[0_0_6px_#e8a44a80]" : "bg-base-400"
}`}
/>
</div>
);
})}
</div>
</section>
)}
</div>
</div>
</div>
);

View file

@ -133,18 +133,34 @@ export const MOCK_DATA: DashboardData = {
{ entity_id: "light.balkon", name: "Balkon", state: "off", brightness: 0 },
{ entity_id: "light.gaestezimmer", name: "Gästezimmer", state: "off", brightness: 0 },
],
switches: [
{ entity_id: "switch.buro_pc", name: "Büro PC", state: "on" },
{ entity_id: "switch.teich", name: "Teich Socket", state: "on" },
{ entity_id: "switch.kinderzimmer", name: "Kinderzimmer Plug", state: "off" },
],
covers: [
{ entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", position: 100 },
{ entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", position: 0 },
{ entity_id: "cover.kueche", name: "Küche Rollo", state: "open", position: 75 },
{ entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", current_position: 100, device_class: "shutter" },
{ entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", current_position: 0, device_class: "shutter" },
{ entity_id: "cover.kueche", name: "Küche Rollo", state: "open", current_position: 75, device_class: "curtain" },
],
sensors: [
{ entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, unit: "°C" },
{ entity_id: "sensor.temp_aussen", name: "Außen", state: 7.8, unit: "°C" },
{ entity_id: "sensor.temp_schlafzimmer", name: "Schlafzimmer", state: 19.2, unit: "°C" },
{ entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, unit: "°C", device_class: "temperature" },
{ entity_id: "sensor.temp_aussen", name: "Außen", state: 7.8, unit: "°C", device_class: "temperature" },
{ entity_id: "sensor.temp_schlafzimmer", name: "Schlafzimmer", state: 19.2, unit: "°C", device_class: "temperature" },
{ entity_id: "sensor.hum_wohnzimmer", name: "Wohnzimmer", state: 48.2, unit: "%", device_class: "humidity" },
{ entity_id: "sensor.hum_kueche", name: "Küche", state: 33.4, unit: "%", device_class: "humidity" },
],
binary_sensors: [
{ entity_id: "binary_sensor.front_door", name: "Haustür", state: "off", device_class: "door" },
{ entity_id: "binary_sensor.motion_og", name: "Bewegung OG", state: "off", device_class: "occupancy" },
],
climate: [
{ entity_id: "climate.klima", name: "Klima", state: "heat", current_temperature: 23, target_temperature: 25, hvac_modes: ["off", "heat", "cool"], humidity: 28 },
],
lights_on: 3,
lights_total: 8,
switches_on: 2,
switches_total: 3,
},
mqtt: {
connected: true,

View file

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