diff --git a/apps/ui/static/README.md b/apps/ui/static/README.md new file mode 100644 index 0000000..333da95 --- /dev/null +++ b/apps/ui/static/README.md @@ -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 + + +``` + +## 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` +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` +Lädt alle Geräte mit ihren Features. + +```javascript +const devices = await window.apiClient.getDevices(); +// [{device_id: "...", name: "...", type: "light", features: {...}}, ...] +``` + +#### `getDeviceState(deviceId): Promise` +Lädt den aktuellen Status eines Geräts. + +```javascript +const state = await window.apiClient.getDeviceState('kitchen_light'); +// {power: true, brightness: 80, ...} +``` + +#### `getAllStates(): Promise` +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` +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` +Lädt alle verfügbaren Szenen. + +```javascript +const scenes = await window.apiClient.getScenes(); +``` + +#### `activateScene(sceneId): Promise` +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 + + + + + My Page + + + + +
+ + + + + +``` + +## 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.) diff --git a/apps/ui/static/api-client.js b/apps/ui/static/api-client.js new file mode 100644 index 0000000..1217e82 --- /dev/null +++ b/apps/ui/static/api-client.js @@ -0,0 +1,262 @@ +/** + * 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 { + constructor() { + this.baseUrl = window.API_BASE || ''; + this.eventSource = null; + this.eventListeners = []; + } + + /** + * Helper to construct full API URLs + * @param {string} path - API path (e.g., '/devices') + * @returns {string} Full URL + */ + api(path) { + return `${this.baseUrl}${path}`; + } + + /** + * Generic fetch wrapper with error handling + * @param {string} url - URL to fetch + * @param {object} options - Fetch options + * @returns {Promise} 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>} + */ + async getDevices() { + return await this.fetch(this.api('/devices')); + } + + /** + * Get current state of a specific device + * @param {string} deviceId - Device ID + * @returns {Promise} Device state + */ + async getDeviceState(deviceId) { + return await this.fetch(this.api(`/devices/${deviceId}/state`)); + } + + /** + * Get all device states at once + * @returns {Promise} 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} + */ + 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>} + */ + async getScenes() { + return await this.fetch(this.api('/scenes')); + } + + /** + * Activate a scene + * @param {string} sceneId - Scene ID + * @returns {Promise} + */ + 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); + + return room.devices + .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); +} diff --git a/apps/ui/static/types.js b/apps/ui/static/types.js new file mode 100644 index 0000000..017b0fe --- /dev/null +++ b/apps/ui/static/types.js @@ -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 {string[]} devices - Array of device IDs 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} 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(); + */ diff --git a/apps/ui/templates/device.html b/apps/ui/templates/device.html index 2ad82c6..0cc8180 100644 --- a/apps/ui/templates/device.html +++ b/apps/ui/templates/device.html @@ -4,6 +4,8 @@ Gerät - Home Automation + +