separation of ui and static
Some checks failed
ci/woodpecker/tag/build/5 Pipeline failed
ci/woodpecker/tag/predeploy Pipeline was successful
ci/woodpecker/tag/deploy/2 unknown status
ci/woodpecker/tag/deploy/1 unknown status
ci/woodpecker/tag/build/2 Pipeline failed
ci/woodpecker/tag/deploy/4 unknown status
ci/woodpecker/tag/deploy/3 unknown status
ci/woodpecker/tag/build/3 Pipeline failed
ci/woodpecker/tag/deploy/5 unknown status
ci/woodpecker/tag/ingress unknown status
ci/woodpecker/tag/build/1 Pipeline failed
ci/woodpecker/tag/build/4 Pipeline failed
15
apps/static/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# Static assets Dockerfile (minimal webserver for /static only)
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# Remove default nginx content
|
||||
RUN rm -rf ./*
|
||||
|
||||
# Copy only static assets from the UI project
|
||||
COPY apps/ui/static/ ./
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Use default nginx config; caller can mount custom config if needed
|
||||
301
apps/static/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.)
|
||||
290
apps/static/static/api-client.js
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* 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'));
|
||||
}
|
||||
|
||||
async getDevice(deviceId) {
|
||||
return await this.fetch(this.api(`/devices/${deviceId}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const requestBody = { type, payload };
|
||||
console.log('API setDeviceState request:', requestBody);
|
||||
await fetch(this.api(`/devices/${deviceId}/set`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
const realtimeUrl = this.api('/realtime');
|
||||
console.log('Connecting to SSE endpoint:', realtimeUrl);
|
||||
this.eventSource = new EventSource(realtimeUrl);
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
console.log('Raw SSE event received:', event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Parsed SSE data:', 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
|
||||
};
|
||||
|
||||
console.log('Normalized SSE event:', normalizedEvent);
|
||||
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, 'Raw data:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onopen = (event) => {
|
||||
console.log('SSE connection opened:', event);
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
console.log('EventSource readyState:', this.eventSource.readyState);
|
||||
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);
|
||||
}
|
||||
BIN
apps/static/static/apple-touch-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
apps/static/static/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 639 B |
BIN
apps/static/static/apple-touch-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 827 B |
BIN
apps/static/static/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 884 B |
BIN
apps/static/static/apple-touch-icon-16x16.png
Normal file
|
After Width: | Height: | Size: 153 B |
BIN
apps/static/static/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 1018 B |
BIN
apps/static/static/apple-touch-icon-32x32.png
Normal file
|
After Width: | Height: | Size: 210 B |
BIN
apps/static/static/apple-touch-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 336 B |
BIN
apps/static/static/apple-touch-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 346 B |
BIN
apps/static/static/apple-touch-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
apps/static/static/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 432 B |
BIN
apps/static/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1018 B |
4
apps/static/static/apple-touch-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="180" height="180" rx="40" fill="#667EEA"/>
|
||||
<text x="90" y="130" font-size="80" text-anchor="middle" fill="white">🏡</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
13
apps/static/static/favicon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Roof -->
|
||||
<path d="M50 10 L90 45 L85 45 L85 50 L15 50 L15 45 L10 45 Z" fill="#667eea" stroke="#4c51bf" stroke-width="2" stroke-linejoin="round"/>
|
||||
<!-- House body -->
|
||||
<rect x="15" y="45" width="70" height="45" fill="#764ba2" stroke="#4c51bf" stroke-width="2"/>
|
||||
<!-- Door -->
|
||||
<rect x="35" y="60" width="15" height="30" fill="#4c51bf" rx="2"/>
|
||||
<!-- Window -->
|
||||
<rect x="60" y="60" width="20" height="15" fill="#fbbf24" stroke="#f59e0b" stroke-width="1"/>
|
||||
<!-- Window panes -->
|
||||
<line x1="70" y1="60" x2="70" y2="75" stroke="#f59e0b" stroke-width="1"/>
|
||||
<line x1="60" y1="67.5" x2="80" y2="67.5" stroke="#f59e0b" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 721 B |
BIN
apps/static/static/garage-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 519 B |
BIN
apps/static/static/garage-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 547 B |
BIN
apps/static/static/garage-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 641 B |
BIN
apps/static/static/garage-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 695 B |
BIN
apps/static/static/garage-icon-16x16.png
Normal file
|
After Width: | Height: | Size: 126 B |
BIN
apps/static/static/garage-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 808 B |
BIN
apps/static/static/garage-icon-32x32.png
Normal file
|
After Width: | Height: | Size: 192 B |
BIN
apps/static/static/garage-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 257 B |
BIN
apps/static/static/garage-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 271 B |
BIN
apps/static/static/garage-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
apps/static/static/garage-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
apps/static/static/garage-icon.png
Normal file
|
After Width: | Height: | Size: 808 B |
4
apps/static/static/garage-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="180" height="180" rx="40" fill="#667EEA"/>
|
||||
<text x="90" y="130" font-size="80" text-anchor="middle" fill="white">🚗</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
43
apps/static/static/manifest.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "Home Automation",
|
||||
"short_name": "Home",
|
||||
"description": "Smart Home Automation System",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#667eea",
|
||||
"theme_color": "#667eea",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/apple-touch-icon-180x180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/apple-touch-icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/apple-touch-icon-120x120.png",
|
||||
"sizes": "120x120",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/apple-touch-icon-76x76.png",
|
||||
"sizes": "76x76",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/apple-touch-icon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/apple-touch-icon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
257
apps/static/static/style.css
Normal file
@@ -0,0 +1,257 @@
|
||||
/* Home Automation Dashboard Styles */
|
||||
|
||||
:root {
|
||||
--primary-color: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--bg-color: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
main {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Room Section */
|
||||
.room {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Device Grid */
|
||||
.devices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Tablet: 2 columns */
|
||||
@media (max-width: 1024px) {
|
||||
.devices-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: 1 column */
|
||||
@media (max-width: 640px) {
|
||||
.devices-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Device Tile */
|
||||
.device-tile {
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.device-tile:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.device-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-id {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Device State */
|
||||
.device-state {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.375rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Device Controls */
|
||||
.device-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.btn-on {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-on:hover {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
.btn-off {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-off:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 0.5rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.room {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
166
apps/static/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();
|
||||
*/
|
||||