Compare commits
22 Commits
homekit_in
...
3e02decc02
| Author | SHA1 | Date | |
|---|---|---|---|
|
3e02decc02
|
|||
|
3e9e388ddd
|
|||
|
1f6fe134d5
|
|||
|
05884bb99a
|
|||
|
5d08ec970a
|
|||
|
732c333966
|
|||
|
c685e65be5
|
|||
|
2eb9d323de
|
|||
|
d9d2033dd7
|
|||
|
04c8cc8577
|
|||
|
e140227f7a
|
|||
|
cc25a22025
|
|||
|
af6ab012dd
|
|||
|
a2c2ef7ddd
|
|||
|
ecf31c7f8b
|
|||
|
74d4fea695
|
|||
|
c38fdab0ad
|
|||
|
094cef0bd7
|
|||
|
1027b905b5
|
|||
|
204a70a438
|
|||
|
3a2702e9aa
|
|||
|
9e66e16986
|
@@ -1,3 +1,4 @@
|
||||
|
||||
"""API main entry point.
|
||||
|
||||
API-Analyse für HomeKit-Bridge Kompatibilität
|
||||
@@ -212,6 +213,73 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/devices/{device_id}/layout")
|
||||
async def get_device_layout(device_id: str):
|
||||
"""Gibt die layout-spezifischen Informationen für ein einzelnes Gerät zurück."""
|
||||
layout = load_layout()
|
||||
for room in layout.rooms:
|
||||
for device in room.devices:
|
||||
if device.device_id == device_id:
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"room": room.name,
|
||||
"title": device.title,
|
||||
"icon": device.icon,
|
||||
"rank": device.rank,
|
||||
}
|
||||
raise HTTPException(status_code=404, detail="Device layout not found")
|
||||
|
||||
@app.get("/devices/{device_id}/state")
|
||||
async def get_device_state(device_id: str):
|
||||
"""Gibt den aktuellen State für ein einzelnes Gerät zurück."""
|
||||
state_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
||||
if not state_path.exists():
|
||||
raise HTTPException(status_code=500, detail="State file not found")
|
||||
with open(state_path, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
states = config.get("states", {})
|
||||
state = states.get(device_id)
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="Device state not found")
|
||||
return state
|
||||
# --- Minimal-invasive: Einzelgerät-Layout-Endpunkt ---
|
||||
from fastapi import Query
|
||||
|
||||
|
||||
|
||||
# --- Minimal-invasive: Einzelgerät-Layout-Endpunkt ---
|
||||
@app.get("/devices/{device_id}/layout")
|
||||
async def get_device_layout(device_id: str):
|
||||
"""Gibt die layout-spezifischen Informationen für ein einzelnes Gerät zurück."""
|
||||
layout = load_layout()
|
||||
for room in layout.get("rooms", []):
|
||||
for device in room.get("devices", []):
|
||||
if device.get("device_id") == device_id:
|
||||
# Rückgabe: Layout-Infos + Raumname
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"room": room.get("name"),
|
||||
"title": device.get("title"),
|
||||
"icon": device.get("icon"),
|
||||
"rank": device.get("rank"),
|
||||
}
|
||||
raise HTTPException(status_code=404, detail="Device layout not found")
|
||||
|
||||
# --- Minimal-invasive: Einzelgerät-State-Endpunkt ---
|
||||
@app.get("/devices/{device_id}/state")
|
||||
async def get_device_state(device_id: str):
|
||||
"""Gibt den aktuellen State für ein einzelnes Gerät zurück."""
|
||||
# States werden wie im Bulk-Endpoint geladen
|
||||
state_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
||||
if not state_path.exists():
|
||||
raise HTTPException(status_code=500, detail="State file not found")
|
||||
with open(state_path, "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
states = config.get("states", {})
|
||||
state = states.get(device_id)
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="Device state not found")
|
||||
return state
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
|
||||
@@ -77,6 +77,58 @@ async def index(request: Request) -> HTMLResponse:
|
||||
return await dashboard(request)
|
||||
|
||||
|
||||
@app.get("/rooms", response_class=HTMLResponse)
|
||||
async def rooms(request: Request) -> HTMLResponse:
|
||||
"""Render the rooms overview page.
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object
|
||||
|
||||
Returns:
|
||||
HTMLResponse: Rendered rooms template
|
||||
"""
|
||||
return templates.TemplateResponse("rooms.html", {
|
||||
"request": request,
|
||||
"api_base": API_BASE
|
||||
})
|
||||
|
||||
|
||||
@app.get("/room/{room_name}", response_class=HTMLResponse)
|
||||
async def room_detail(request: Request, room_name: str) -> HTMLResponse:
|
||||
"""Render the room detail page with devices.
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object
|
||||
room_name: Name of the room to display
|
||||
|
||||
Returns:
|
||||
HTMLResponse: Rendered room template
|
||||
"""
|
||||
return templates.TemplateResponse("room.html", {
|
||||
"request": request,
|
||||
"api_base": API_BASE,
|
||||
"room_name": room_name
|
||||
})
|
||||
|
||||
|
||||
@app.get("/device/{device_id}", response_class=HTMLResponse)
|
||||
async def device_detail(request: Request, device_id: str) -> HTMLResponse:
|
||||
"""Render the device detail page with controls.
|
||||
|
||||
Args:
|
||||
request: The FastAPI request object
|
||||
device_id: ID of the device to display
|
||||
|
||||
Returns:
|
||||
HTMLResponse: Rendered device template
|
||||
"""
|
||||
return templates.TemplateResponse("device.html", {
|
||||
"request": request,
|
||||
"api_base": API_BASE,
|
||||
"device_id": device_id
|
||||
})
|
||||
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request) -> HTMLResponse:
|
||||
"""Render the dashboard with rooms and devices.
|
||||
|
||||
188
apps/ui/redesign_ui.txt
Normal file
188
apps/ui/redesign_ui.txt
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
Copilot-Aufgabe: Erzeuge eine neue Home-Dashboard-Seite mit Raum-Kacheln.
|
||||
|
||||
Ziel:
|
||||
Die Seite soll alle Räume als kleine Kacheln darstellen. Auf dem iPhone
|
||||
sollen immer zwei Kacheln nebeneinander passen. Jede Kachel zeigt:
|
||||
- Raumname
|
||||
- Icon (z. B. Wohnzimmer, Küche, Bad, etc.) basierend auf room_id oder einem Mapping
|
||||
- Anzahl der Geräte im Raum
|
||||
- Optional: Zusammenfassung wichtiger States (z.B. Anzahl offener Fenster, aktive Lichter)
|
||||
|
||||
Datenquelle:
|
||||
- GET /layout → { "rooms": [{ "name": "...", "devices": [...] }] }
|
||||
(Achtung: rooms ist ein Array, kein Dictionary!)
|
||||
- GET /devices → Geräteliste für Feature-Checks
|
||||
|
||||
Interaktion:
|
||||
- Beim Klick/Touch auf eine Raum-Kachel → Navigation zu /room/{room_name}
|
||||
|
||||
Layout-Anforderungen:
|
||||
- 2-Spalten-Grid auf kleinen Screens (max-width ~ 600px)
|
||||
- 3–4 Spalten auf größeren Screens
|
||||
- Kachelgröße kompakt (ca. 140px x 110px)
|
||||
- Icon ~32px
|
||||
- Text ~14–16px
|
||||
- Responsive via CSS-Grid oder Flexbox
|
||||
- Minimaler Einsatz von Tailwind (bevorzugt vanilla CSS)
|
||||
|
||||
Akzeptanzkriterien:
|
||||
- Die Seite lädt alle Räume über die API (fetch).
|
||||
- Räume werden in der Reihenfolge aus layout.yaml angezeigt.
|
||||
- Jede Kachel zeigt: Icon + Raumname + Geräteanzahl.
|
||||
- iPhone-Darstellung verifiziert: zwei Kacheln nebeneinander.
|
||||
- Funktionierende Navigation zu /room/{room_name}.
|
||||
- Die Komponente ist vollständig lauffähig.
|
||||
- Fehlerbehandlung bei API-Fehlern implementiert.
|
||||
*/
|
||||
|
||||
/**
|
||||
Copilot-Aufgabe: Erzeuge eine Geräte-Grid-Ansicht für einen Raum.
|
||||
|
||||
Ziel:
|
||||
Die Seite zeigt alle Geräte, die in diesem Raum laut layout.yaml liegen.
|
||||
Die Darstellung erfolgt als kompakte Kacheln, ebenfalls 2 Spalten auf iPhone.
|
||||
|
||||
Datenquelle:
|
||||
- GET /layout → Räume + device_id + title
|
||||
- GET /devices → Typ + Features
|
||||
- GET /devices/{id}/state (optional zur Initialisierung)
|
||||
- Live-Updates: SSE /realtime
|
||||
|
||||
Auf einer Gerät-Kachel sollen erscheinen:
|
||||
- passendes Icon (abhängig von type)
|
||||
- title (aus layout)
|
||||
- wichtigste Eigenschaft aus dem State:
|
||||
- light: power on/off oder brightness in %
|
||||
- thermostat: current temperature
|
||||
- contact: open/closed
|
||||
- temp_humidity: temperature und/oder humidity
|
||||
- outlet: on/off
|
||||
- cover: position %
|
||||
|
||||
Interaktion:
|
||||
- Klick/Touch → Navigation zu /device/{device_id}
|
||||
|
||||
Akzeptanzkriterien:
|
||||
- Der Raum wird anhand room_id aus der URL geladen.
|
||||
- Geräte werden über Join(layout, devices) des Raums selektiert.
|
||||
- Kacheln sind 2-spaltig auf iPhone.
|
||||
- State wird initial geladen und per SSE aktualisiert.
|
||||
- Navigation zu /device/{id} funktioniert.
|
||||
- Icons passend zum Typ generiert.
|
||||
*/
|
||||
|
||||
/**
|
||||
Copilot-Aufgabe: Erzeuge eine Detailansicht für ein einzelnes Gerät.
|
||||
|
||||
Ziel:
|
||||
Die Seite zeigt:
|
||||
- Titel des Geräts (title aus layout)
|
||||
- Raumname
|
||||
- Gerätetyp
|
||||
- State-Werte aus GET /devices/{id}/state
|
||||
- Live-Updates via SSE
|
||||
- Steuer-Elemente abhängig vom type + features:
|
||||
- light: toggle, brightness-slider, optional color-picker
|
||||
- thermostat: target-temp-slider
|
||||
- outlet: toggle
|
||||
- contact: nur Anzeige
|
||||
- temp_humidity: nur Anzeigen von Temperatur/Humidity
|
||||
- cover: position-slider und open/close/stop Buttons
|
||||
|
||||
API-Integration:
|
||||
- Set-Kommandos senden via POST /devices/{id}/set
|
||||
- Validierung: Nur unterstützte Features sichtbar machen
|
||||
|
||||
UI-Vorgaben:
|
||||
- Kompakt, aber komplett
|
||||
- Buttons gut für Touch erreichbar
|
||||
- Slider in voller Breite
|
||||
- Werte (temperature, humidity, battery) übersichtlich gruppiert
|
||||
|
||||
Akzeptanzkriterien:
|
||||
- Device wird korrekt geladen (layout + devices + state).
|
||||
- Steuerung funktioniert (light on/off, brightness, target temp etc.).
|
||||
- SSE aktualisiert alle angezeigten Werte live.
|
||||
- Fehler (z. B. POST /set nicht erreichbar) werden UI-seitig angezeigt.
|
||||
*/
|
||||
|
||||
/**
|
||||
Copilot-Aufgabe: Erzeuge einen API-Client für das UI.
|
||||
|
||||
Der Client soll bereitstellen:
|
||||
- getLayout(): Layout-Daten
|
||||
- getDevices(): Device-Basisdaten
|
||||
- getDeviceState(device_id)
|
||||
- setDeviceState(device_id, type, payload)
|
||||
- connectRealtime(onEvent): SSE-Listener
|
||||
|
||||
Anforderungen:
|
||||
- API_BASE aus .env oder UI-Konfiguration
|
||||
- Fehlerbehandlung
|
||||
- Timeout optional
|
||||
- Types für:
|
||||
- Room
|
||||
- Device
|
||||
- DeviceState
|
||||
- RealtimeEvent
|
||||
|
||||
Akzeptanzkriterien:
|
||||
- Der Client ist voll funktionsfähig und wird im UI genutzt.
|
||||
- Ein Hook useRealtime(device_id) wird erzeugt.
|
||||
- Ein Hook useRooms() and useDevices() existieren.
|
||||
*/
|
||||
|
||||
/**
|
||||
Copilot-Aufgabe: Erzeuge das UI-Routing.
|
||||
|
||||
Routen:
|
||||
- "/" → Home (Räume)
|
||||
- "/room/:roomId" → RoomView
|
||||
- "/device/:deviceId" → DeviceView
|
||||
|
||||
Anforderungen:
|
||||
- React Router v6 oder v7
|
||||
- Layout-Komponente optional
|
||||
- Loading/Fehlerzustände
|
||||
- Responsive Verhalten beibehalten
|
||||
|
||||
Akzeptanzkriterien:
|
||||
- Navigation funktioniert zwischen allen Seiten.
|
||||
- Browser-Back funktioniert erwartungsgemäß.
|
||||
- Routes unterstützen Refresh ohne Fehler.
|
||||
*/
|
||||
|
||||
/**
|
||||
Copilot-Aufgabe: Implementiere einen React-Hook useRealtime(deviceId: string | null).
|
||||
|
||||
Ziel:
|
||||
- SSE-Stream /realtime abonnieren
|
||||
- Nur Events für deviceId liefern
|
||||
- onMessage → setState
|
||||
- automatische Reconnects
|
||||
- Fehlerlogging
|
||||
|
||||
Akzeptanz:
|
||||
- Der Hook kann in RoomView & DeviceView genutzt werden.
|
||||
- Live-Updates werden korrekt gemerged.
|
||||
- Disconnect/Reload funktioniert sauber.
|
||||
*/
|
||||
|
||||
/**
|
||||
Copilot-Aufgabe: Erzeuge eine Icon-Komponente.
|
||||
|
||||
Ziel:
|
||||
Basierend auf device.type und ggf. features ein passendes SVG ausliefern:
|
||||
- light → Lightbulb
|
||||
- thermostat → Thermostat
|
||||
- contact → Door/Window-Sensor
|
||||
- temp_humidity → Thermometer+Droplet
|
||||
- outlet → Power-Plug
|
||||
- cover → Blinds/Rollershutter
|
||||
|
||||
Akzeptanz:
|
||||
- Icons skalieren sauber
|
||||
- funktionieren in allen Kachel-Komponenten
|
||||
*/
|
||||
|
||||
301
apps/ui/static/README.md
Normal file
301
apps/ui/static/README.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Home Automation API Client
|
||||
|
||||
Wiederverwendbare JavaScript-API-Client-Bibliothek für das Home Automation UI.
|
||||
|
||||
## Installation
|
||||
|
||||
Füge die folgenden Script-Tags in deine HTML-Seiten ein:
|
||||
|
||||
```html
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Der API-Client nutzt `window.API_BASE`, das vom Backend gesetzt wird:
|
||||
|
||||
```javascript
|
||||
window.API_BASE = '{{ api_base }}'; // Jinja2 template
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Globale Instanz
|
||||
|
||||
Der API-Client erstellt automatisch eine globale Instanz `window.apiClient`:
|
||||
|
||||
```javascript
|
||||
// Layout abrufen
|
||||
const layout = await window.apiClient.getLayout();
|
||||
|
||||
// Geräte abrufen
|
||||
const devices = await window.apiClient.getDevices();
|
||||
|
||||
// Gerätestatus abrufen
|
||||
const state = await window.apiClient.getDeviceState('kitchen_light');
|
||||
|
||||
// Gerätesteuerung
|
||||
await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
||||
power: true,
|
||||
brightness: 80
|
||||
});
|
||||
```
|
||||
|
||||
### Verfügbare Methoden
|
||||
|
||||
#### `getLayout(): Promise<Layout>`
|
||||
Lädt die Layout-Daten (Räume und ihre Geräte).
|
||||
|
||||
```javascript
|
||||
const layout = await window.apiClient.getLayout();
|
||||
// { rooms: [{name: "Küche", devices: ["kitchen_light", ...]}, ...] }
|
||||
```
|
||||
|
||||
#### `getDevices(): Promise<Device[]>`
|
||||
Lädt alle Geräte mit ihren Features.
|
||||
|
||||
```javascript
|
||||
const devices = await window.apiClient.getDevices();
|
||||
// [{device_id: "...", name: "...", type: "light", features: {...}}, ...]
|
||||
```
|
||||
|
||||
#### `getDeviceState(deviceId): Promise<DeviceState>`
|
||||
Lädt den aktuellen Status eines Geräts.
|
||||
|
||||
```javascript
|
||||
const state = await window.apiClient.getDeviceState('kitchen_light');
|
||||
// {power: true, brightness: 80, ...}
|
||||
```
|
||||
|
||||
#### `getAllStates(): Promise<Object>`
|
||||
Lädt alle Gerätestatus auf einmal.
|
||||
|
||||
```javascript
|
||||
const states = await window.apiClient.getAllStates();
|
||||
// {"kitchen_light": {power: true, ...}, "thermostat_1": {...}, ...}
|
||||
```
|
||||
|
||||
#### `setDeviceState(deviceId, type, payload): Promise<void>`
|
||||
Sendet einen Befehl an ein Gerät.
|
||||
|
||||
```javascript
|
||||
// Licht einschalten
|
||||
await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
||||
power: true,
|
||||
brightness: 80
|
||||
});
|
||||
|
||||
// Thermostat einstellen
|
||||
await window.apiClient.setDeviceState('thermostat_1', 'thermostat', {
|
||||
target_temp: 22.5
|
||||
});
|
||||
|
||||
// Rollladen steuern
|
||||
await window.apiClient.setDeviceState('cover_1', 'cover', {
|
||||
position: 50
|
||||
});
|
||||
```
|
||||
|
||||
#### `getDeviceRoom(deviceId): Promise<{room: string}>`
|
||||
Ermittelt den Raum eines Geräts.
|
||||
|
||||
```javascript
|
||||
const { room } = await window.apiClient.getDeviceRoom('kitchen_light');
|
||||
// {room: "Küche"}
|
||||
```
|
||||
|
||||
#### `getScenes(): Promise<Scene[]>`
|
||||
Lädt alle verfügbaren Szenen.
|
||||
|
||||
```javascript
|
||||
const scenes = await window.apiClient.getScenes();
|
||||
```
|
||||
|
||||
#### `activateScene(sceneId): Promise<void>`
|
||||
Aktiviert eine Szene.
|
||||
|
||||
```javascript
|
||||
await window.apiClient.activateScene('evening');
|
||||
```
|
||||
|
||||
### Realtime-Updates (SSE)
|
||||
|
||||
#### `connectRealtime(onEvent, onError): EventSource`
|
||||
Verbindet sich mit dem SSE-Stream für Live-Updates.
|
||||
|
||||
```javascript
|
||||
window.apiClient.connectRealtime(
|
||||
(event) => {
|
||||
console.log('Update:', event.device_id, event.state);
|
||||
// event = {device_id: "...", type: "state", state: {...}}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Connection error:', error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
#### `onDeviceUpdate(deviceId, callback): Function`
|
||||
Registriert einen Listener für spezifische Geräte-Updates.
|
||||
|
||||
```javascript
|
||||
// Für ein bestimmtes Gerät
|
||||
const unsubscribe = window.apiClient.onDeviceUpdate('kitchen_light', (event) => {
|
||||
console.log('Kitchen light changed:', event.state);
|
||||
updateUI(event.state);
|
||||
});
|
||||
|
||||
// Für alle Geräte
|
||||
const unsubscribeAll = window.apiClient.onDeviceUpdate(null, (event) => {
|
||||
console.log('Any device changed:', event.device_id, event.state);
|
||||
});
|
||||
|
||||
// Später: Listener entfernen
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
#### `disconnectRealtime(): void`
|
||||
Trennt die SSE-Verbindung und entfernt alle Listener.
|
||||
|
||||
```javascript
|
||||
window.apiClient.disconnectRealtime();
|
||||
```
|
||||
|
||||
### Helper-Methoden
|
||||
|
||||
#### `findDevice(devices, deviceId): Device|null`
|
||||
Findet ein Gerät in einem Array.
|
||||
|
||||
```javascript
|
||||
const devices = await window.apiClient.getDevices();
|
||||
const device = window.apiClient.findDevice(devices, 'kitchen_light');
|
||||
```
|
||||
|
||||
#### `findRoom(layout, roomName): Room|null`
|
||||
Findet einen Raum im Layout.
|
||||
|
||||
```javascript
|
||||
const layout = await window.apiClient.getLayout();
|
||||
const room = window.apiClient.findRoom(layout, 'Küche');
|
||||
```
|
||||
|
||||
#### `getDevicesForRoom(layout, devices, roomName): Device[]`
|
||||
Gibt alle Geräte eines Raums zurück.
|
||||
|
||||
```javascript
|
||||
const layout = await window.apiClient.getLayout();
|
||||
const devices = await window.apiClient.getDevices();
|
||||
const kitchenDevices = window.apiClient.getDevicesForRoom(layout, devices, 'Küche');
|
||||
```
|
||||
|
||||
#### `api(path): string`
|
||||
Konstruiert eine vollständige API-URL.
|
||||
|
||||
```javascript
|
||||
const url = window.apiClient.api('/devices');
|
||||
// "http://172.19.1.11:8001/devices"
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
Die globale `api()` Funktion ist weiterhin verfügbar:
|
||||
|
||||
```javascript
|
||||
function api(url) {
|
||||
return window.apiClient.api(url);
|
||||
}
|
||||
```
|
||||
|
||||
## Typen (JSDoc)
|
||||
|
||||
Die Datei `types.js` enthält JSDoc-Definitionen für alle API-Typen:
|
||||
|
||||
- `Room` - Raum mit Geräten
|
||||
- `Layout` - Layout-Struktur
|
||||
- `Device` - Gerätedaten
|
||||
- `DeviceFeatures` - Geräte-Features
|
||||
- `DeviceState` - Gerätestatus (Light, Thermostat, Contact, etc.)
|
||||
- `RealtimeEvent` - SSE-Event-Format
|
||||
- `Scene` - Szenen-Definition
|
||||
- `*Payload` - Command-Payloads für verschiedene Gerätetypen
|
||||
|
||||
Diese ermöglichen IDE-Autocomplete und Type-Checking in modernen Editoren (VS Code, WebStorm).
|
||||
|
||||
## Beispiel: Vollständige Seite
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>My Page</title>
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status"></div>
|
||||
<button id="toggle">Toggle Light</button>
|
||||
|
||||
<script>
|
||||
window.API_BASE = 'http://172.19.1.11:8001';
|
||||
const deviceId = 'kitchen_light';
|
||||
|
||||
async function init() {
|
||||
// Load initial state
|
||||
const state = await window.apiClient.getDeviceState(deviceId);
|
||||
updateUI(state);
|
||||
|
||||
// Listen for updates
|
||||
window.apiClient.onDeviceUpdate(deviceId, (event) => {
|
||||
updateUI(event.state);
|
||||
});
|
||||
|
||||
// Connect to realtime
|
||||
window.apiClient.connectRealtime((event) => {
|
||||
console.log('Event:', event);
|
||||
});
|
||||
|
||||
// Handle button clicks
|
||||
document.getElementById('toggle').onclick = async () => {
|
||||
const currentState = await window.apiClient.getDeviceState(deviceId);
|
||||
await window.apiClient.setDeviceState(deviceId, 'light', {
|
||||
power: !currentState.power
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function updateUI(state) {
|
||||
document.getElementById('status').textContent =
|
||||
state.power ? 'ON' : 'OFF';
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Alle API-Methoden werfen Exceptions bei Fehlern:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const state = await window.apiClient.getDeviceState('invalid_id');
|
||||
} catch (error) {
|
||||
console.error('API error:', error);
|
||||
showErrorMessage(error.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Reconnect
|
||||
|
||||
Der SSE-Client versucht automatisch, nach 5 Sekunden wieder zu verbinden, wenn die Verbindung abbricht.
|
||||
|
||||
## Verwendete Technologien
|
||||
|
||||
- **Fetch API** - Für HTTP-Requests
|
||||
- **EventSource** - Für Server-Sent Events
|
||||
- **JSDoc** - Für Type Definitions
|
||||
- **ES6+** - Modern JavaScript (Class, async/await, etc.)
|
||||
274
apps/ui/static/api-client.js
Normal file
274
apps/ui/static/api-client.js
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Home Automation API Client
|
||||
*
|
||||
* Provides a unified interface to interact with the backend API.
|
||||
* All functions use the global window.API_BASE configuration.
|
||||
*/
|
||||
|
||||
class HomeAutomationClient {
|
||||
/**
|
||||
* Get layout info for a specific device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @returns {Promise<object>} Layout info
|
||||
*/
|
||||
async getDeviceLayout(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}/layout`));
|
||||
}
|
||||
constructor() {
|
||||
this.eventSource = null;
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to construct full API URLs
|
||||
* Reads window.API_BASE at runtime to support dynamic configuration
|
||||
* @param {string} path - API path (e.g., '/devices')
|
||||
* @returns {string} Full URL
|
||||
*/
|
||||
api(path) {
|
||||
const baseUrl = window.API_BASE || '';
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fetch wrapper with error handling
|
||||
* @param {string} url - URL to fetch
|
||||
* @param {object} options - Fetch options
|
||||
* @returns {Promise<any>} Response data
|
||||
*/
|
||||
async fetch(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layout data (rooms and their devices)
|
||||
* @returns {Promise<{rooms: Array<{name: string, devices: string[]}>}>}
|
||||
*/
|
||||
async getLayout() {
|
||||
return await this.fetch(this.api('/layout'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices with their features
|
||||
* @returns {Promise<Array<{device_id: string, name: string, type: string, features: object}>>}
|
||||
*/
|
||||
async getDevices() {
|
||||
return await this.fetch(this.api('/devices'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current state of a specific device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @returns {Promise<object>} Device state
|
||||
*/
|
||||
async getDeviceState(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}/state`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all device states at once
|
||||
* @returns {Promise<object>} Map of device_id to state
|
||||
*/
|
||||
async getAllStates() {
|
||||
return await this.fetch(this.api('/devices/states'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to a device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @param {string} type - Device type (light, thermostat, etc.)
|
||||
* @param {object} payload - Command payload
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setDeviceState(deviceId, type, payload) {
|
||||
await fetch(this.api(`/devices/${deviceId}/set`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ type, payload })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get room information for a device
|
||||
* @param {string} deviceId - Device ID
|
||||
* @returns {Promise<{room: string}>}
|
||||
*/
|
||||
async getDeviceRoom(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}/room`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available scenes
|
||||
* @returns {Promise<Array<{scene_id: string, name: string, devices: object}>>}
|
||||
*/
|
||||
async getScenes() {
|
||||
return await this.fetch(this.api('/scenes'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a scene
|
||||
* @param {string} sceneId - Scene ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async activateScene(sceneId) {
|
||||
await fetch(this.api(`/scenes/${sceneId}/activate`), {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to realtime event stream (SSE)
|
||||
* @param {Function} onEvent - Callback function(event)
|
||||
* @param {Function} onError - Error callback (optional)
|
||||
* @returns {EventSource} EventSource instance
|
||||
*/
|
||||
connectRealtime(onEvent, onError = null) {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
}
|
||||
|
||||
this.eventSource = new EventSource(this.api('/realtime'));
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Normalize event format: convert API format to unified format
|
||||
const normalizedEvent = {
|
||||
device_id: data.device_id,
|
||||
type: data.type,
|
||||
state: data.payload || data.state // Support both formats
|
||||
};
|
||||
|
||||
onEvent(normalizedEvent);
|
||||
|
||||
// Notify all registered listeners
|
||||
this.eventListeners.forEach(listener => {
|
||||
if (!listener.deviceId || listener.deviceId === normalizedEvent.device_id) {
|
||||
listener.callback(normalizedEvent);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
|
||||
// Auto-reconnect after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.connectRealtime(onEvent, onError);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
return this.eventSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for specific device updates
|
||||
* @param {string|null} deviceId - Device ID or null for all devices
|
||||
* @param {Function} callback - Callback function(event)
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
onDeviceUpdate(deviceId, callback) {
|
||||
const listener = { deviceId, callback };
|
||||
this.eventListeners.push(listener);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.eventListeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
this.eventListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from realtime stream
|
||||
*/
|
||||
disconnectRealtime() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
this.eventListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get device by ID from devices array
|
||||
* @param {Array} devices - Devices array
|
||||
* @param {string} deviceId - Device ID to find
|
||||
* @returns {object|null} Device object or null
|
||||
*/
|
||||
findDevice(devices, deviceId) {
|
||||
return devices.find(d => d.device_id === deviceId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get room by name from layout
|
||||
* @param {object} layout - Layout object
|
||||
* @param {string} roomName - Room name to find
|
||||
* @returns {object|null} Room object or null
|
||||
*/
|
||||
findRoom(layout, roomName) {
|
||||
return layout.rooms.find(r => r.name === roomName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get devices for a specific room
|
||||
* @param {object} layout - Layout object
|
||||
* @param {Array} devices - Devices array
|
||||
* @param {string} roomName - Room name
|
||||
* @returns {Array} Array of device objects
|
||||
*/
|
||||
getDevicesForRoom(layout, devices, roomName) {
|
||||
const room = this.findRoom(layout, roomName);
|
||||
if (!room) return [];
|
||||
|
||||
const deviceMap = {};
|
||||
devices.forEach(d => deviceMap[d.device_id] = d);
|
||||
|
||||
// Extract device IDs from room.devices (they are objects with device_id property)
|
||||
const deviceIds = room.devices.map(d => d.device_id || d);
|
||||
|
||||
return deviceIds
|
||||
.map(id => deviceMap[id])
|
||||
.filter(d => d != null);
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.apiClient = new HomeAutomationClient();
|
||||
|
||||
/**
|
||||
* Convenience function for backward compatibility
|
||||
*/
|
||||
function api(url) {
|
||||
return window.apiClient.api(url);
|
||||
}
|
||||
166
apps/ui/static/types.js
Normal file
166
apps/ui/static/types.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Type definitions for Home Automation API
|
||||
*
|
||||
* These are JSDoc type definitions that provide IDE autocomplete
|
||||
* and type checking in JavaScript files.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Room
|
||||
* @property {string} name - Room name (e.g., "Küche", "Wohnzimmer")
|
||||
* @property {Array<{device_id: string, title: string, icon: string, rank: number}>} devices - Array of device objects in this room
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Layout
|
||||
* @property {Room[]} rooms - Array of rooms with their devices
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DeviceFeatures
|
||||
* @property {boolean} [dimmable] - Light: supports brightness control
|
||||
* @property {boolean} [color_hsb] - Light: supports HSB color control
|
||||
* @property {boolean} [color_temp] - Light: supports color temperature
|
||||
* @property {number} [min_temp] - Thermostat: minimum temperature
|
||||
* @property {number} [max_temp] - Thermostat: maximum temperature
|
||||
* @property {number} [step] - Thermostat: temperature step size
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Device
|
||||
* @property {string} device_id - Unique device identifier
|
||||
* @property {string} name - Human-readable device name
|
||||
* @property {string} type - Device type: light, thermostat, contact, temp_humidity_sensor, relay, outlet, cover
|
||||
* @property {DeviceFeatures} features - Device-specific features
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LightState
|
||||
* @property {boolean} power - On/off state
|
||||
* @property {number} [brightness] - Brightness 0-100 (if dimmable)
|
||||
* @property {Object} [color_hsb] - HSB color (if color_hsb)
|
||||
* @property {number} color_hsb.hue - Hue 0-360
|
||||
* @property {number} color_hsb.saturation - Saturation 0-100
|
||||
* @property {number} color_hsb.brightness - Brightness 0-100
|
||||
* @property {number} [color_temp] - Color temperature in mireds (if color_temp)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ThermostatState
|
||||
* @property {number} current_temp - Current temperature in °C
|
||||
* @property {number} target_temp - Target temperature in °C
|
||||
* @property {string} mode - Operating mode: heat, cool, auto, off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContactState
|
||||
* @property {boolean} open - true if open, false if closed
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TempHumidityState
|
||||
* @property {number} temperature - Temperature in °C
|
||||
* @property {number} humidity - Relative humidity 0-100%
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RelayState
|
||||
* @property {boolean} power - On/off state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OutletState
|
||||
* @property {boolean} power - On/off state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CoverState
|
||||
* @property {number} position - Position 0-100 (0=closed, 100=open)
|
||||
* @property {string} state - State: open, closed, opening, closing, stopped
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {LightState|ThermostatState|ContactState|TempHumidityState|RelayState|OutletState|CoverState} DeviceState
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RealtimeEvent
|
||||
* @property {string} device_id - Device that changed
|
||||
* @property {string} type - Device type
|
||||
* @property {DeviceState} state - New device state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Scene
|
||||
* @property {string} scene_id - Unique scene identifier
|
||||
* @property {string} name - Human-readable scene name
|
||||
* @property {Object<string, Object>} devices - Map of device_id to desired state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LightPayload
|
||||
* @property {boolean} [power] - Turn on/off
|
||||
* @property {number} [brightness] - Set brightness 0-100
|
||||
* @property {Object} [color_hsb] - Set HSB color
|
||||
* @property {number} color_hsb.hue - Hue 0-360
|
||||
* @property {number} color_hsb.saturation - Saturation 0-100
|
||||
* @property {number} color_hsb.brightness - Brightness 0-100
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ThermostatPayload
|
||||
* @property {number} [target_temp] - Set target temperature
|
||||
* @property {string} [mode] - Set mode: heat, cool, auto, off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RelayPayload
|
||||
* @property {boolean} power - Turn on/off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OutletPayload
|
||||
* @property {boolean} power - Turn on/off
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CoverPayload
|
||||
* @property {number} [position] - Set position 0-100
|
||||
* @property {string} [action] - Action: open, close, stop
|
||||
*/
|
||||
|
||||
/**
|
||||
* Example usage:
|
||||
*
|
||||
* // Get layout data
|
||||
* const layout = await window.apiClient.getLayout();
|
||||
* // layout is typed as Layout
|
||||
*
|
||||
* // Get devices
|
||||
* const devices = await window.apiClient.getDevices();
|
||||
* // devices is typed as Device[]
|
||||
*
|
||||
* // Get device state
|
||||
* const state = await window.apiClient.getDeviceState('kitchen_light');
|
||||
* // state is typed as DeviceState
|
||||
*
|
||||
* // Set device state
|
||||
* await window.apiClient.setDeviceState('kitchen_light', 'light', {
|
||||
* power: true,
|
||||
* brightness: 80
|
||||
* });
|
||||
*
|
||||
* // Connect to realtime events
|
||||
* window.apiClient.connectRealtime((event) => {
|
||||
* console.log('Device update:', event.device_id, event.state);
|
||||
* });
|
||||
*
|
||||
* // Listen to specific device
|
||||
* const unsubscribe = window.apiClient.onDeviceUpdate('kitchen_light', (event) => {
|
||||
* console.log('Kitchen light changed:', event.state);
|
||||
* });
|
||||
*
|
||||
* // Later: cleanup
|
||||
* unsubscribe();
|
||||
* window.apiClient.disconnectRealtime();
|
||||
*/
|
||||
826
apps/ui/templates/device.html
Normal file
826
apps/ui/templates/device.html
Normal file
@@ -0,0 +1,826 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gerät - Home Automation</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 12px;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-button.on {
|
||||
background: #34c759;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-button.off {
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.toggle-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #e0e0e0;
|
||||
outline: none;
|
||||
border-radius: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #667eea;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #667eea;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
background: #e0e0e0;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.state-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.state-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.state-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-badge.open {
|
||||
background: #ff9500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.state-badge.closed {
|
||||
background: #34c759;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 59, 48, 0.9);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: rgba(52, 199, 89, 0.9);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="#" id="back-button" class="back-button">← Zurück</a>
|
||||
<div class="device-icon" id="device-icon">📱</div>
|
||||
<h1 id="device-name">Gerät wird geladen...</h1>
|
||||
<div class="device-meta" id="device-room"></div>
|
||||
<div class="device-meta" id="device-type"></div>
|
||||
</div>
|
||||
|
||||
<div id="error-container"></div>
|
||||
<div id="loading" class="loading">Lade Gerät...</div>
|
||||
<div id="controls-container" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API configuration from backend
|
||||
window.API_BASE = '{{ api_base }}';
|
||||
</script>
|
||||
|
||||
<!-- Load API client AFTER API_BASE is set -->
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Get device ID from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const deviceId = pathParts[pathParts.length - 1];
|
||||
|
||||
// Device data
|
||||
let deviceData = null;
|
||||
let deviceState = {};
|
||||
let roomName = '';
|
||||
|
||||
// Device type icons
|
||||
const deviceIcons = {
|
||||
'light': '💡',
|
||||
'thermostat': '🌡️',
|
||||
'contact': '🚪',
|
||||
'temp_humidity_sensor': '🌡️',
|
||||
'relay': '🔌',
|
||||
'outlet': '🔌',
|
||||
'cover': '🪟'
|
||||
};
|
||||
|
||||
async function loadDevice() {
|
||||
const loading = document.getElementById('loading');
|
||||
const controlsContainer = document.getElementById('controls-container');
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
|
||||
try {
|
||||
// Load device info using API client
|
||||
// NEW: Use new endpoints for device info and layout
|
||||
deviceData = await window.apiClient.getDeviceState(deviceId);
|
||||
const layoutInfo = await window.apiClient.getDeviceLayout(deviceId);
|
||||
roomName = layoutInfo.room;
|
||||
// deviceState is now the result of getDeviceState
|
||||
deviceState = deviceData;
|
||||
|
||||
// Update header
|
||||
document.getElementById('device-icon').textContent = deviceIcons[deviceData.type] || '📱';
|
||||
document.getElementById('device-name').textContent = deviceData.name;
|
||||
document.getElementById('device-room').textContent = roomName || 'Kein Raum';
|
||||
document.getElementById('device-type').textContent = getTypeLabel(deviceData.type);
|
||||
|
||||
// Set back button
|
||||
document.getElementById('back-button').href = roomName ? `/room/${encodeURIComponent(roomName)}` : '/rooms';
|
||||
|
||||
// Render controls
|
||||
loading.style.display = 'none';
|
||||
controlsContainer.style.display = 'block';
|
||||
renderControls(controlsContainer);
|
||||
|
||||
// Start SSE
|
||||
connectRealtime();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading device:', error);
|
||||
loading.style.display = 'none';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error">
|
||||
⚠️ Fehler beim Laden: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type) {
|
||||
const labels = {
|
||||
'light': 'Licht',
|
||||
'thermostat': 'Thermostat',
|
||||
'contact': 'Kontaktsensor',
|
||||
'temp_humidity_sensor': 'Temperatur & Luftfeuchte',
|
||||
'relay': 'Schalter',
|
||||
'outlet': 'Steckdose',
|
||||
'cover': 'Jalousie'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function renderControls(container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
switch (deviceData.type) {
|
||||
case 'light':
|
||||
renderLightControls(container);
|
||||
break;
|
||||
case 'thermostat':
|
||||
renderThermostatControls(container);
|
||||
break;
|
||||
case 'relay':
|
||||
case 'outlet':
|
||||
renderOutletControls(container);
|
||||
break;
|
||||
case 'contact':
|
||||
renderContactDisplay(container);
|
||||
break;
|
||||
case 'temp_humidity_sensor':
|
||||
renderTempHumidityDisplay(container);
|
||||
break;
|
||||
case 'cover':
|
||||
renderCoverControls(container);
|
||||
break;
|
||||
default:
|
||||
container.innerHTML = '<div class="card">Keine Steuerung verfügbar</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderLightControls(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">Steuerung</div>';
|
||||
|
||||
// Power toggle
|
||||
const powerGroup = document.createElement('div');
|
||||
powerGroup.className = 'control-group';
|
||||
const powerButton = document.createElement('button');
|
||||
powerButton.className = 'toggle-button ' + (deviceState.power === 'on' ? 'on' : 'off');
|
||||
powerButton.textContent = deviceState.power === 'on' ? '💡 An' : '💡 Aus';
|
||||
powerButton.onclick = () => togglePower();
|
||||
powerGroup.appendChild(powerButton);
|
||||
card.appendChild(powerGroup);
|
||||
|
||||
// Brightness slider (if supported)
|
||||
if (deviceData.features?.brightness) {
|
||||
const brightnessGroup = document.createElement('div');
|
||||
brightnessGroup.className = 'control-group';
|
||||
brightnessGroup.innerHTML = `
|
||||
<label class="control-label">Helligkeit</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" class="slider" id="brightness-slider" min="0" max="100" value="${deviceState.brightness || 0}">
|
||||
<div class="slider-value" id="brightness-value">${deviceState.brightness || 0}%</div>
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(brightnessGroup);
|
||||
|
||||
setTimeout(() => {
|
||||
const slider = document.getElementById('brightness-slider');
|
||||
const valueDisplay = document.getElementById('brightness-value');
|
||||
slider.oninput = (e) => {
|
||||
valueDisplay.textContent = e.target.value + '%';
|
||||
};
|
||||
slider.onchange = (e) => {
|
||||
setBrightness(parseInt(e.target.value));
|
||||
};
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Color picker (if supported)
|
||||
if (deviceData.features?.color_hsb) {
|
||||
const colorGroup = document.createElement('div');
|
||||
colorGroup.className = 'control-group';
|
||||
colorGroup.innerHTML = `
|
||||
<label class="control-label">Farbe</label>
|
||||
<input type="color" class="color-picker" id="color-picker" value="#ffffff">
|
||||
`;
|
||||
card.appendChild(colorGroup);
|
||||
|
||||
setTimeout(() => {
|
||||
const picker = document.getElementById('color-picker');
|
||||
picker.onchange = (e) => {
|
||||
setColor(e.target.value);
|
||||
};
|
||||
}, 0);
|
||||
}
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function renderThermostatControls(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
|
||||
// Current state display
|
||||
const stateGrid = document.createElement('div');
|
||||
stateGrid.className = 'state-grid';
|
||||
stateGrid.innerHTML = `
|
||||
<div class="state-item">
|
||||
<div class="state-value" id="current-temp">${deviceState.current_temp?.toFixed(1) || '--'}°C</div>
|
||||
<div class="state-label">Aktuell</div>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<div class="state-value" id="target-temp">${deviceState.target_temp?.toFixed(1) || '--'}°C</div>
|
||||
<div class="state-label">Ziel</div>
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(stateGrid);
|
||||
|
||||
// Target temperature slider
|
||||
const sliderGroup = document.createElement('div');
|
||||
sliderGroup.className = 'control-group';
|
||||
sliderGroup.style.marginTop = '20px';
|
||||
sliderGroup.innerHTML = `
|
||||
<label class="control-label">Zieltemperatur</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" class="slider" id="temp-slider" min="5" max="30" step="0.5" value="${deviceState.target_temp || 21}">
|
||||
<div class="slider-value" id="temp-value">${deviceState.target_temp?.toFixed(1) || '21.0'}°C</div>
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(sliderGroup);
|
||||
|
||||
container.appendChild(card);
|
||||
|
||||
setTimeout(() => {
|
||||
const slider = document.getElementById('temp-slider');
|
||||
const valueDisplay = document.getElementById('temp-value');
|
||||
slider.oninput = (e) => {
|
||||
valueDisplay.textContent = parseFloat(e.target.value).toFixed(1) + '°C';
|
||||
};
|
||||
slider.onchange = (e) => {
|
||||
setTargetTemp(parseFloat(e.target.value));
|
||||
};
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function renderOutletControls(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">Steuerung</div>';
|
||||
|
||||
const powerGroup = document.createElement('div');
|
||||
powerGroup.className = 'control-group';
|
||||
const powerButton = document.createElement('button');
|
||||
powerButton.className = 'toggle-button ' + (deviceState.power === 'on' ? 'on' : 'off');
|
||||
powerButton.textContent = deviceState.power === 'on' ? '🔌 An' : '🔌 Aus';
|
||||
powerButton.onclick = () => togglePower();
|
||||
powerGroup.appendChild(powerButton);
|
||||
card.appendChild(powerGroup);
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function renderContactDisplay(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">Status</div>';
|
||||
|
||||
const statusDiv = document.createElement('div');
|
||||
statusDiv.style.textAlign = 'center';
|
||||
const isOpen = deviceState.contact === 'open';
|
||||
statusDiv.innerHTML = `
|
||||
<div class="state-badge ${isOpen ? 'open' : 'closed'}" id="contact-status">
|
||||
${isOpen ? 'Offen' : 'Geschlossen'}
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(statusDiv);
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function renderTempHumidityDisplay(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">Messwerte</div>';
|
||||
|
||||
const stateGrid = document.createElement('div');
|
||||
stateGrid.className = 'state-grid';
|
||||
stateGrid.innerHTML = `
|
||||
<div class="state-item">
|
||||
<div class="state-value" id="temperature">${deviceState.temperature?.toFixed(1) || '--'}°C</div>
|
||||
<div class="state-label">Temperatur</div>
|
||||
</div>
|
||||
<div class="state-item">
|
||||
<div class="state-value" id="humidity">${deviceState.humidity || '--'}%</div>
|
||||
<div class="state-label">Luftfeuchte</div>
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(stateGrid);
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
function renderCoverControls(container) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">Position</div>';
|
||||
|
||||
// Position slider
|
||||
const sliderGroup = document.createElement('div');
|
||||
sliderGroup.className = 'control-group';
|
||||
sliderGroup.innerHTML = `
|
||||
<div class="slider-container">
|
||||
<input type="range" class="slider" id="position-slider" min="0" max="100" value="${deviceState.position || 0}">
|
||||
<div class="slider-value" id="position-value">${deviceState.position || 0}%</div>
|
||||
</div>
|
||||
`;
|
||||
card.appendChild(sliderGroup);
|
||||
|
||||
// Control buttons
|
||||
const buttonGroup = document.createElement('div');
|
||||
buttonGroup.className = 'button-group';
|
||||
buttonGroup.style.marginTop = '16px';
|
||||
buttonGroup.innerHTML = `
|
||||
<button class="control-button" onclick="setCoverPosition(0)">Auf</button>
|
||||
<button class="control-button" onclick="stopCover()">Stop</button>
|
||||
<button class="control-button" onclick="setCoverPosition(100)">Zu</button>
|
||||
`;
|
||||
card.appendChild(buttonGroup);
|
||||
|
||||
container.appendChild(card);
|
||||
|
||||
setTimeout(() => {
|
||||
const slider = document.getElementById('position-slider');
|
||||
const valueDisplay = document.getElementById('position-value');
|
||||
slider.oninput = (e) => {
|
||||
valueDisplay.textContent = e.target.value + '%';
|
||||
};
|
||||
slider.onchange = (e) => {
|
||||
setCoverPosition(parseInt(e.target.value));
|
||||
};
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Control functions
|
||||
async function togglePower() {
|
||||
const newState = deviceState.power === 'on' ? 'off' : 'on';
|
||||
await sendCommand({
|
||||
type: deviceData.type,
|
||||
payload: { power: newState }
|
||||
});
|
||||
}
|
||||
|
||||
async function setBrightness(value) {
|
||||
await sendCommand({
|
||||
type: 'light',
|
||||
payload: { brightness: value }
|
||||
});
|
||||
}
|
||||
|
||||
async function setColor(hexColor) {
|
||||
// Convert hex to HSB
|
||||
const hsb = hexToHSB(hexColor);
|
||||
await sendCommand({
|
||||
type: 'light',
|
||||
payload: {
|
||||
hue: Math.round(hsb.h),
|
||||
sat: Math.round(hsb.s)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function setTargetTemp(value) {
|
||||
await sendCommand({
|
||||
type: 'thermostat',
|
||||
payload: { target: value }
|
||||
});
|
||||
}
|
||||
|
||||
async function setCoverPosition(value) {
|
||||
await sendCommand({
|
||||
type: 'cover',
|
||||
payload: { position: value }
|
||||
});
|
||||
}
|
||||
|
||||
async function stopCover() {
|
||||
await sendCommand({
|
||||
type: 'cover',
|
||||
payload: { action: 'stop' }
|
||||
});
|
||||
}
|
||||
|
||||
async function sendCommand(payload) {
|
||||
try {
|
||||
await window.apiClient.setDeviceState(deviceId, deviceData.type, payload.payload);
|
||||
showSuccess('Befehl gesendet');
|
||||
} catch (error) {
|
||||
console.error('Error sending command:', error);
|
||||
showError('Fehler beim Senden: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const container = document.getElementById('controls-container');
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'success';
|
||||
successDiv.textContent = '✓ ' + message;
|
||||
container.appendChild(successDiv);
|
||||
setTimeout(() => successDiv.remove(), 3000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const container = document.getElementById('error-container');
|
||||
container.innerHTML = `<div class="error">⚠️ ${message}</div>`;
|
||||
setTimeout(() => container.innerHTML = '', 5000);
|
||||
}
|
||||
|
||||
function hexToHSB(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const diff = max - min;
|
||||
|
||||
let h = 0;
|
||||
if (diff !== 0) {
|
||||
if (max === r) h = ((g - b) / diff) % 6;
|
||||
else if (max === g) h = (b - r) / diff + 2;
|
||||
else h = (r - g) / diff + 4;
|
||||
}
|
||||
h = Math.round(h * 60);
|
||||
if (h < 0) h += 360;
|
||||
|
||||
const s = max === 0 ? 0 : (diff / max) * 100;
|
||||
|
||||
return { h, s, b: max * 100 };
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
try {
|
||||
// Use API client's realtime connection
|
||||
window.apiClient.connectRealtime((event) => {
|
||||
if (event.device_id === deviceId && event.state) {
|
||||
deviceState = { ...deviceState, ...event.state };
|
||||
updateUI();
|
||||
}
|
||||
}, (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to realtime events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
// Update based on device type
|
||||
switch (deviceData.type) {
|
||||
case 'light':
|
||||
updateLightUI();
|
||||
break;
|
||||
case 'thermostat':
|
||||
updateThermostatUI();
|
||||
break;
|
||||
case 'relay':
|
||||
case 'outlet':
|
||||
updateOutletUI();
|
||||
break;
|
||||
case 'contact':
|
||||
updateContactUI();
|
||||
break;
|
||||
case 'temp_humidity_sensor':
|
||||
updateTempHumidityUI();
|
||||
break;
|
||||
case 'cover':
|
||||
updateCoverUI();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateLightUI() {
|
||||
const button = document.querySelector('.toggle-button');
|
||||
if (button) {
|
||||
button.className = 'toggle-button ' + (deviceState.power === 'on' ? 'on' : 'off');
|
||||
button.textContent = deviceState.power === 'on' ? '💡 An' : '💡 Aus';
|
||||
}
|
||||
|
||||
const brightnessSlider = document.getElementById('brightness-slider');
|
||||
const brightnessValue = document.getElementById('brightness-value');
|
||||
if (brightnessSlider && deviceState.brightness != null) {
|
||||
brightnessSlider.value = deviceState.brightness;
|
||||
brightnessValue.textContent = deviceState.brightness + '%';
|
||||
}
|
||||
}
|
||||
|
||||
function updateThermostatUI() {
|
||||
const currentTemp = document.getElementById('current-temp');
|
||||
const targetTemp = document.getElementById('target-temp');
|
||||
const tempSlider = document.getElementById('temp-slider');
|
||||
const tempValue = document.getElementById('temp-value');
|
||||
|
||||
if (currentTemp && deviceState.current_temp != null) {
|
||||
currentTemp.textContent = deviceState.current_temp.toFixed(1) + '°C';
|
||||
}
|
||||
if (targetTemp && deviceState.target_temp != null) {
|
||||
targetTemp.textContent = deviceState.target_temp.toFixed(1) + '°C';
|
||||
}
|
||||
if (tempSlider && deviceState.target_temp != null) {
|
||||
tempSlider.value = deviceState.target_temp;
|
||||
tempValue.textContent = deviceState.target_temp.toFixed(1) + '°C';
|
||||
}
|
||||
}
|
||||
|
||||
function updateOutletUI() {
|
||||
const button = document.querySelector('.toggle-button');
|
||||
if (button) {
|
||||
button.className = 'toggle-button ' + (deviceState.power === 'on' ? 'on' : 'off');
|
||||
button.textContent = deviceState.power === 'on' ? '🔌 An' : '🔌 Aus';
|
||||
}
|
||||
}
|
||||
|
||||
function updateContactUI() {
|
||||
const status = document.getElementById('contact-status');
|
||||
if (status && deviceState.contact) {
|
||||
const isOpen = deviceState.contact === 'open';
|
||||
status.className = 'state-badge ' + (isOpen ? 'open' : 'closed');
|
||||
status.textContent = isOpen ? 'Offen' : 'Geschlossen';
|
||||
}
|
||||
}
|
||||
|
||||
function updateTempHumidityUI() {
|
||||
const temperature = document.getElementById('temperature');
|
||||
const humidity = document.getElementById('humidity');
|
||||
|
||||
if (temperature && deviceState.temperature != null) {
|
||||
temperature.textContent = deviceState.temperature.toFixed(1) + '°C';
|
||||
}
|
||||
if (humidity && deviceState.humidity != null) {
|
||||
humidity.textContent = deviceState.humidity + '%';
|
||||
}
|
||||
}
|
||||
|
||||
function updateCoverUI() {
|
||||
const slider = document.getElementById('position-slider');
|
||||
const value = document.getElementById('position-value');
|
||||
|
||||
if (slider && deviceState.position != null) {
|
||||
slider.value = deviceState.position;
|
||||
value.textContent = deviceState.position + '%';
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
window.apiClient.disconnectRealtime();
|
||||
});
|
||||
|
||||
// Load device on page load
|
||||
document.addEventListener('DOMContentLoaded', loadDevice);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
460
apps/ui/templates/room.html
Normal file
460
apps/ui/templates/room.html
Normal file
@@ -0,0 +1,460 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ room_name }} - Home Automation</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 12px;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.devices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.devices-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.devices-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.device-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.device-card:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.device-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.device-state {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.state-primary {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.state-secondary {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.state-badge.on {
|
||||
background: #34c759;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.state-badge.off {
|
||||
background: #e0e0e0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.state-badge.open {
|
||||
background: #ff9500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.state-badge.closed {
|
||||
background: #34c759;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 59, 48, 0.9);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-devices {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="/rooms" class="back-button">← Zurück zu Räumen</a>
|
||||
<h1 id="room-name">Raum wird geladen...</h1>
|
||||
<div class="room-info" id="room-info"></div>
|
||||
</div>
|
||||
|
||||
<div id="error-container"></div>
|
||||
<div id="loading" class="loading">Lade Geräte...</div>
|
||||
<div id="devices-grid" class="devices-grid" style="display: none;"></div>
|
||||
<div id="no-devices" class="no-devices" style="display: none;">
|
||||
Keine Geräte in diesem Raum gefunden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API configuration from backend
|
||||
window.API_BASE = '{{ api_base }}';
|
||||
</script>
|
||||
|
||||
<!-- Load API client AFTER API_BASE is set -->
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Get room name from URL
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const roomName = decodeURIComponent(pathParts[pathParts.length - 1]);
|
||||
|
||||
// Device type to icon mapping
|
||||
const deviceIcons = {
|
||||
'light': '💡',
|
||||
'thermostat': '🌡️',
|
||||
'contact': '🚪',
|
||||
'temp_humidity_sensor': '🌡️',
|
||||
'relay': '🔌',
|
||||
'outlet': '🔌',
|
||||
'cover': '🪟'
|
||||
};
|
||||
|
||||
// Device states
|
||||
const deviceStates = {};
|
||||
|
||||
async function loadRoom() {
|
||||
const loading = document.getElementById('loading');
|
||||
const grid = document.getElementById('devices-grid');
|
||||
const noDevices = document.getElementById('no-devices');
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
const roomNameEl = document.getElementById('room-name');
|
||||
const roomInfoEl = document.getElementById('room-info');
|
||||
|
||||
try {
|
||||
// Load layout and devices using API client
|
||||
// NEW: Use device layout endpoint for each device in this room
|
||||
const layoutData = await window.apiClient.getLayout();
|
||||
const devicesData = await window.apiClient.getDevices();
|
||||
// Example: For each device in room.devices, you could fetch layout info via
|
||||
// await window.apiClient.fetch(window.apiClient.api(`/devices/${device_id}/layout`));
|
||||
|
||||
console.log('Room name from URL:', roomName);
|
||||
console.log('Available rooms:', layoutData.rooms.map(r => r.name));
|
||||
console.log('Total devices:', devicesData.length);
|
||||
|
||||
// Find the room using API client helper
|
||||
const room = window.apiClient.findRoom(layoutData, roomName);
|
||||
if (!room) {
|
||||
console.error('Room not found:', roomName);
|
||||
throw new Error(`Raum "${roomName}" nicht gefunden`);
|
||||
}
|
||||
|
||||
console.log('Found room:', room);
|
||||
console.log('Room devices:', room.devices);
|
||||
|
||||
// Update header
|
||||
roomNameEl.textContent = room.name;
|
||||
roomInfoEl.textContent = `${room.devices.length} Gerät${room.devices.length !== 1 ? 'e' : ''}`;
|
||||
|
||||
// Create device lookup
|
||||
const deviceMap = {};
|
||||
devicesData.forEach(device => {
|
||||
deviceMap[device.device_id] = device;
|
||||
});
|
||||
|
||||
// Extract device IDs from room devices (they are objects now)
|
||||
const deviceIds = room.devices.map(d => d.device_id);
|
||||
console.log('Device IDs from room:', deviceIds);
|
||||
|
||||
// Filter devices for this room
|
||||
const roomDevices = deviceIds
|
||||
.map(deviceId => deviceMap[deviceId])
|
||||
.filter(device => device != null);
|
||||
|
||||
console.log('Filtered room devices:', roomDevices);
|
||||
|
||||
loading.style.display = 'none';
|
||||
|
||||
if (roomDevices.length === 0) {
|
||||
noDevices.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Lade nur die States für die Geräte im aktuellen Raum
|
||||
for (const device of roomDevices) {
|
||||
try {
|
||||
deviceStates[device.device_id] = await window.apiClient.getDeviceState(device.device_id);
|
||||
} catch (err) {
|
||||
deviceStates[device.device_id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render devices
|
||||
grid.style.display = 'grid';
|
||||
roomDevices.forEach(device => {
|
||||
const card = createDeviceCard(device);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
// Start SSE for live updates
|
||||
connectRealtime();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading room:', error);
|
||||
loading.style.display = 'none';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error">
|
||||
⚠️ Fehler beim Laden: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeviceCard(device) {
|
||||
const card = document.createElement('a');
|
||||
card.className = 'device-card';
|
||||
card.href = `/device/${device.device_id}`;
|
||||
card.dataset.deviceId = device.device_id;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'device-header';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'device-icon';
|
||||
icon.textContent = deviceIcons[device.type] || '📱';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'device-title';
|
||||
title.textContent = device.name;
|
||||
|
||||
header.appendChild(icon);
|
||||
header.appendChild(title);
|
||||
|
||||
const stateDiv = document.createElement('div');
|
||||
stateDiv.className = 'device-state';
|
||||
updateDeviceCardState(stateDiv, device);
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(stateDiv);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateDeviceCardState(stateDiv, device) {
|
||||
const state = deviceStates[device.device_id];
|
||||
if (!state) {
|
||||
stateDiv.innerHTML = '<div class="state-secondary">Status unbekannt</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
switch (device.type) {
|
||||
case 'light':
|
||||
if (state.power) {
|
||||
const powerState = state.power === 'on';
|
||||
html = `<span class="state-badge ${powerState ? 'on' : 'off'}">${powerState ? 'An' : 'Aus'}</span>`;
|
||||
if (powerState && state.brightness != null) {
|
||||
html += `<div class="state-secondary">${state.brightness}% Helligkeit</div>`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thermostat':
|
||||
if (state.current != null) {
|
||||
html = `<div class="state-primary">${state.current.toFixed(1)}°C</div>`;
|
||||
if (state.target != null) {
|
||||
html += `<div class="state-secondary">Ziel: ${state.target}°C</div>`;
|
||||
}
|
||||
if (state.mode) {
|
||||
html += `<div class="state-secondary">Modus: ${state.mode}</div>`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'contact':
|
||||
if (state.contact != null) {
|
||||
const isOpen = state.contact === 'open';
|
||||
html = `<span class="state-badge ${isOpen ? 'open' : 'closed'}">${isOpen ? 'Offen' : 'Geschlossen'}</span>`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'temp_humidity_sensor':
|
||||
if (state.temperature != null) {
|
||||
html = `<div class="state-primary">${state.temperature.toFixed(1)}°C</div>`;
|
||||
if (state.humidity != null) {
|
||||
html += `<div class="state-secondary">${state.humidity}% Luftfeuchte</div>`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'relay':
|
||||
case 'outlet':
|
||||
if (state.power) {
|
||||
const powerState = state.power === 'on';
|
||||
html = `<span class="state-badge ${powerState ? 'on' : 'off'}">${powerState ? 'An' : 'Aus'}</span>`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cover':
|
||||
if (state.position != null) {
|
||||
html = `<div class="state-primary">${state.position}%</div>`;
|
||||
html += `<div class="state-secondary">Position</div>`;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
html = '<div class="state-secondary">Kein Status</div>';
|
||||
}
|
||||
|
||||
stateDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
try {
|
||||
// Use API client's connectRealtime method
|
||||
window.apiClient.connectRealtime((event) => {
|
||||
// Update device state
|
||||
if (event.device_id && event.state) {
|
||||
deviceStates[event.device_id] = event.state;
|
||||
|
||||
// Update card if visible
|
||||
const card = document.querySelector(`[data-device-id="${event.device_id}"]`);
|
||||
if (card) {
|
||||
const stateDiv = card.querySelector('.device-state');
|
||||
window.apiClient.getDevices().then(devices => {
|
||||
const device = window.apiClient.findDevice(devices, event.device_id);
|
||||
if (device) {
|
||||
updateDeviceCardState(stateDiv, device);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to realtime events:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
window.apiClient.disconnectRealtime();
|
||||
});
|
||||
|
||||
// Load room on page load
|
||||
document.addEventListener('DOMContentLoaded', loadRoom);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
310
apps/ui/templates/rooms.html
Normal file
310
apps/ui/templates/rooms.html
Normal file
@@ -0,0 +1,310 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Räume - Home Automation</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rooms-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.rooms-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.rooms-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.room-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-height: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.room-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.room-card:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.room-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.room-device-count {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.room-stats {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 59, 48, 0.9);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #667eea;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="back-button">← Dashboard</a>
|
||||
|
||||
<div class="container">
|
||||
<h1>🏠 Räume</h1>
|
||||
|
||||
<div id="error-container"></div>
|
||||
<div id="loading" class="loading">Lade Räume...</div>
|
||||
<div id="rooms-grid" class="rooms-grid" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API configuration from backend
|
||||
window.API_BASE = '{{ api_base }}';
|
||||
</script>
|
||||
|
||||
<!-- Load API client AFTER API_BASE is set -->
|
||||
<script src="/static/types.js"></script>
|
||||
<script src="/static/api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Room icon mapping
|
||||
const roomIcons = {
|
||||
'wohnzimmer': '🛋️',
|
||||
'küche': '🍳',
|
||||
'kueche': '🍳',
|
||||
'schlafzimmer': '🛏️',
|
||||
'bad': '🚿',
|
||||
'badezimmer': '🚿',
|
||||
'bad oben': '🚿',
|
||||
'bad unten': '🚿',
|
||||
'flur': '🚪',
|
||||
'büro': '💼',
|
||||
'buero': '💼',
|
||||
'arbeitszimmer': '💼',
|
||||
'studierzimmer': '📚',
|
||||
'esszimmer': '🍽️',
|
||||
'garten': '🌳',
|
||||
'terrasse': '🌿',
|
||||
'garage': '🚗',
|
||||
'keller': '🔧',
|
||||
'dachboden': '📦',
|
||||
'kinderzimmer': '🧸',
|
||||
'patty': '👤',
|
||||
'wolfgang': '👤',
|
||||
'default': '🏡'
|
||||
};
|
||||
|
||||
function getRoomIcon(roomName) {
|
||||
const normalized = roomName.toLowerCase().trim();
|
||||
return roomIcons[normalized] || roomIcons['default'];
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
const loading = document.getElementById('loading');
|
||||
const grid = document.getElementById('rooms-grid');
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
|
||||
try {
|
||||
// Load layout and devices using API client
|
||||
// NEW: Use device layout endpoint for each device
|
||||
// Fallback: load all rooms as before
|
||||
const layoutData = await window.apiClient.getLayout();
|
||||
const devicesData = await window.apiClient.getDevices();
|
||||
// Example: For each device, you could also fetch layout info via
|
||||
// await window.apiClient.fetch(window.apiClient.api(`/devices/${device_id}/layout`));
|
||||
|
||||
// Create device lookup
|
||||
const deviceMap = {};
|
||||
devicesData.forEach(device => {
|
||||
deviceMap[device.device_id] = device;
|
||||
});
|
||||
|
||||
// Render rooms
|
||||
loading.style.display = 'none';
|
||||
grid.style.display = 'grid';
|
||||
|
||||
layoutData.rooms.forEach(room => {
|
||||
const card = createRoomCard(room, deviceMap);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading rooms:', error);
|
||||
loading.style.display = 'none';
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error">
|
||||
⚠️ Fehler beim Laden der Räume: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function createRoomCard(room, deviceMap) {
|
||||
const card = document.createElement('a');
|
||||
card.className = 'room-card';
|
||||
card.href = `/room/${encodeURIComponent(room.name)}`;
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'room-icon';
|
||||
icon.textContent = getRoomIcon(room.name);
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'room-name';
|
||||
name.textContent = room.name;
|
||||
|
||||
const deviceCount = document.createElement('div');
|
||||
deviceCount.className = 'room-device-count';
|
||||
deviceCount.textContent = `${room.devices.length} Gerät${room.devices.length !== 1 ? 'e' : ''}`;
|
||||
|
||||
// Optional: Calculate stats (lights on, windows open, etc.)
|
||||
const stats = calculateRoomStats(room.devices, deviceMap);
|
||||
if (stats) {
|
||||
const statsDiv = document.createElement('div');
|
||||
statsDiv.className = 'room-stats';
|
||||
statsDiv.textContent = stats;
|
||||
card.appendChild(icon);
|
||||
card.appendChild(name);
|
||||
card.appendChild(deviceCount);
|
||||
card.appendChild(statsDiv);
|
||||
} else {
|
||||
card.appendChild(icon);
|
||||
card.appendChild(name);
|
||||
card.appendChild(deviceCount);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function calculateRoomStats(roomDevices, deviceMap) {
|
||||
// Extract device IDs from room devices (they are objects now)
|
||||
const deviceIds = roomDevices.map(d => d.device_id || d);
|
||||
|
||||
// Count device types
|
||||
let lights = 0;
|
||||
let thermostats = 0;
|
||||
let contacts = 0;
|
||||
let sensors = 0;
|
||||
|
||||
deviceIds.forEach(deviceId => {
|
||||
const device = deviceMap[deviceId];
|
||||
if (!device) return;
|
||||
|
||||
switch(device.type) {
|
||||
case 'light':
|
||||
lights++;
|
||||
break;
|
||||
case 'thermostat':
|
||||
thermostats++;
|
||||
break;
|
||||
case 'contact':
|
||||
contacts++;
|
||||
break;
|
||||
case 'temp_humidity_sensor':
|
||||
sensors++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Build compact stats string
|
||||
const parts = [];
|
||||
if (lights > 0) parts.push(`💡${lights}`);
|
||||
if (thermostats > 0) parts.push(`🌡️${thermostats}`);
|
||||
if (contacts > 0) parts.push(`🚪${contacts}`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : null;
|
||||
}
|
||||
|
||||
// Load rooms on page load
|
||||
document.addEventListener('DOMContentLoaded', loadRooms);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -24,6 +24,26 @@ devices:
|
||||
ieee_address: "0xf0d1b8000015480b"
|
||||
model: "AC10691"
|
||||
vendor: "OSRAM"
|
||||
- device_id: stehlampe_esszimmer_spiegel
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x001788010d06ea09"
|
||||
set: "zigbee2mqtt/0x001788010d06ea09/set"
|
||||
- device_id: stehlampe_esszimmer_schrank
|
||||
type: light
|
||||
cap_version: "light@1.2.0"
|
||||
technology: zigbee2mqtt
|
||||
features:
|
||||
power: true
|
||||
brightness: true
|
||||
topics:
|
||||
state: "zigbee2mqtt/0x001788010d09176c"
|
||||
set: "zigbee2mqtt/0x001788010d09176c/set"
|
||||
- device_id: grosse_lampe_wohnzimmer
|
||||
type: relay
|
||||
cap_version: "relay@1.0.0"
|
||||
|
||||
@@ -47,6 +47,14 @@ rooms:
|
||||
title: kleine Lampe links Esszimmer
|
||||
icon: 💡
|
||||
rank: 80
|
||||
- device_id: stehlampe_esszimmer_spiegel
|
||||
title: Stehlampe Esszimmer Spiegel
|
||||
icon: 💡
|
||||
rank: 81
|
||||
- device_id: stehlampe_esszimmer_schrank
|
||||
title: Stehlampe Esszimmer Schrank
|
||||
icon: 💡
|
||||
rank: 82
|
||||
- device_id: kleine_lampe_rechts_esszimmer
|
||||
title: kleine Lampe rechts Esszimmer
|
||||
icon: 💡
|
||||
|
||||
Reference in New Issue
Block a user