vibe coded website (plus captive portal)
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m57s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m48s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m18s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m14s
Some checks failed
ESP-IDF Build / build (esp32c6, release-v5.4) (push) Successful in 3m57s
ESP-IDF Build / build (esp32c6, release-v5.5) (push) Successful in 3m48s
ESP-IDF Build / build (esp32s3, release-v5.4) (push) Failing after 3m18s
ESP-IDF Build / build (esp32s3, release-v5.5) (push) Failing after 3m14s
needs missing ESP32 implementation Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
722
firmware/README-API.md
Normal file
722
firmware/README-API.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# System Control - API Documentation
|
||||
|
||||
This document describes all REST API endpoints and WebSocket messages required for the ESP32 firmware implementation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [REST API Endpoints](#rest-api-endpoints)
|
||||
- [WiFi](#wifi)
|
||||
- [Light Control](#light-control)
|
||||
- [Schema](#schema)
|
||||
- [Devices](#devices)
|
||||
- [Scenes](#scenes)
|
||||
- [WebSocket](#websocket)
|
||||
- [Connection](#connection)
|
||||
- [Client to Server Messages](#client-to-server-messages)
|
||||
- [Server to Client Messages](#server-to-client-messages)
|
||||
|
||||
---
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
### WiFi
|
||||
|
||||
#### Scan Networks
|
||||
|
||||
Scans for available WiFi networks.
|
||||
|
||||
- **URL:** `/api/wifi/scan`
|
||||
- **Method:** `GET`
|
||||
- **Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"ssid": "NetworkName",
|
||||
"rssi": -45
|
||||
},
|
||||
{
|
||||
"ssid": "AnotherNetwork",
|
||||
"rssi": -72
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|--------|------------------------------------|
|
||||
| ssid | string | Network name (SSID) |
|
||||
| rssi | number | Signal strength in dBm |
|
||||
|
||||
---
|
||||
|
||||
#### Save WiFi Configuration
|
||||
|
||||
Saves WiFi credentials and initiates connection.
|
||||
|
||||
- **URL:** `/api/wifi/config`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ssid": "NetworkName",
|
||||
"password": "SecretPassword123"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------|--------|----------|-------------------|
|
||||
| ssid | string | Yes | Network name |
|
||||
| password | string | No | Network password |
|
||||
|
||||
- **Response:** `200 OK` on success, `400 Bad Request` on error
|
||||
|
||||
---
|
||||
|
||||
#### Get WiFi Status
|
||||
|
||||
Returns current WiFi connection status.
|
||||
|
||||
- **URL:** `/api/wifi/status`
|
||||
- **Method:** `GET`
|
||||
- **Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
"ssid": "NetworkName",
|
||||
"ip": "192.168.1.100",
|
||||
"rssi": -45
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|---------|------------------------------------------|
|
||||
| connected | boolean | Whether connected to a network |
|
||||
| ssid | string | Connected network name (if connected) |
|
||||
| ip | string | Assigned IP address (if connected) |
|
||||
| rssi | number | Signal strength in dBm (if connected) |
|
||||
|
||||
---
|
||||
|
||||
### Light Control
|
||||
|
||||
#### Set Light Power
|
||||
|
||||
Turns the main light on or off.
|
||||
|
||||
- **URL:** `/api/light/power`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"on": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|---------|----------|--------------------------|
|
||||
| on | boolean | Yes | `true` = on, `false` = off |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Set Light Mode
|
||||
|
||||
Sets the lighting mode.
|
||||
|
||||
- **URL:** `/api/light/mode`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "simulation"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|--------|----------|------------------------------------------------|
|
||||
| mode | string | Yes | One of: `day`, `night`, `simulation` |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Set Active Schema
|
||||
|
||||
Sets the active schema for simulation mode.
|
||||
|
||||
- **URL:** `/api/light/schema`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "schema_01.csv"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|--------|----------|-------------------------------------------------------|
|
||||
| schema | string | Yes | Schema filename: `schema_01.csv`, `schema_02.csv`, etc. |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Get Light Status
|
||||
|
||||
Returns current light status (alternative to WebSocket).
|
||||
|
||||
- **URL:** `/api/light/status`
|
||||
- **Method:** `GET`
|
||||
- **Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"on": true,
|
||||
"mode": "simulation",
|
||||
"schema": "schema_01.csv",
|
||||
"color": {
|
||||
"r": 255,
|
||||
"g": 240,
|
||||
"b": 220
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------|---------|--------------------------------------|
|
||||
| on | boolean | Current power state |
|
||||
| mode | string | Current mode (day/night/simulation) |
|
||||
| schema | string | Active schema filename |
|
||||
| color | object | Current RGB color being displayed |
|
||||
|
||||
---
|
||||
|
||||
### Schema
|
||||
|
||||
#### Load Schema
|
||||
|
||||
Loads a schema file.
|
||||
|
||||
- **URL:** `/api/schema/{filename}`
|
||||
- **Method:** `GET`
|
||||
- **URL Parameters:**
|
||||
- `filename`: Schema file name (e.g., `schema_01.csv`)
|
||||
- **Response:** CSV text data
|
||||
|
||||
```
|
||||
255,240,220,0,100,250
|
||||
255,230,200,0,120,250
|
||||
...
|
||||
```
|
||||
|
||||
The CSV format has 48 rows (one per 30-minute interval) with 6 values per row:
|
||||
|
||||
| Column | Description | Range |
|
||||
|--------|--------------------------------|---------|
|
||||
| 1 | Red (R) | 0-255 |
|
||||
| 2 | Green (G) | 0-255 |
|
||||
| 3 | Blue (B) | 0-255 |
|
||||
| 4 | Value 1 (V1) - custom value | 0-255 |
|
||||
| 5 | Value 2 (V2) - custom value | 0-255 |
|
||||
| 6 | Value 3 (V3) - custom value | 0-255 |
|
||||
|
||||
---
|
||||
|
||||
#### Save Schema
|
||||
|
||||
Saves a schema file.
|
||||
|
||||
- **URL:** `/api/schema/{filename}`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `text/csv`
|
||||
- **URL Parameters:**
|
||||
- `filename`: Schema file name (e.g., `schema_01.csv`)
|
||||
- **Request Body:** CSV text data (same format as above)
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
### Devices
|
||||
|
||||
#### Scan for Devices
|
||||
|
||||
Scans for available Matter devices to pair.
|
||||
|
||||
- **URL:** `/api/devices/scan`
|
||||
- **Method:** `GET`
|
||||
- **Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "matter-001",
|
||||
"type": "light",
|
||||
"name": "Matter Lamp"
|
||||
},
|
||||
{
|
||||
"id": "matter-002",
|
||||
"type": "sensor",
|
||||
"name": "Temperature Sensor"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|--------|-----------------------------------------------|
|
||||
| id | string | Unique device identifier |
|
||||
| type | string | Device type: `light`, `sensor`, `unknown` |
|
||||
| name | string | Device name (can be empty) |
|
||||
|
||||
---
|
||||
|
||||
#### Pair Device
|
||||
|
||||
Pairs a discovered device.
|
||||
|
||||
- **URL:** `/api/devices/pair`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "matter-001",
|
||||
"name": "Living Room Lamp"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|--------|----------|------------------------------|
|
||||
| id | string | Yes | Device ID from scan |
|
||||
| name | string | Yes | User-defined device name |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Get Paired Devices
|
||||
|
||||
Returns list of all paired devices.
|
||||
|
||||
- **URL:** `/api/devices/paired`
|
||||
- **Method:** `GET`
|
||||
- **Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "matter-001",
|
||||
"type": "light",
|
||||
"name": "Living Room Lamp"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|--------|-------------------------------------------|
|
||||
| id | string | Unique device identifier |
|
||||
| type | string | Device type: `light`, `sensor`, `unknown` |
|
||||
| name | string | User-defined device name |
|
||||
|
||||
---
|
||||
|
||||
#### Update Device Name
|
||||
|
||||
Updates the name of a paired device.
|
||||
|
||||
- **URL:** `/api/devices/update`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "matter-001",
|
||||
"name": "New Device Name"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|--------|----------|------------------------|
|
||||
| id | string | Yes | Device ID |
|
||||
| name | string | Yes | New device name |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Unpair Device
|
||||
|
||||
Removes a paired device.
|
||||
|
||||
- **URL:** `/api/devices/unpair`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "matter-001"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|--------|----------|---------------|
|
||||
| id | string | Yes | Device ID |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Toggle Device
|
||||
|
||||
Toggles a device (e.g., light on/off).
|
||||
|
||||
- **URL:** `/api/devices/toggle`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "matter-001"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|--------|----------|---------------|
|
||||
| id | string | Yes | Device ID |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
### Scenes
|
||||
|
||||
#### Get All Scenes
|
||||
|
||||
Returns all configured scenes.
|
||||
|
||||
- **URL:** `/api/scenes`
|
||||
- **Method:** `GET`
|
||||
- **Response:**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "scene-1",
|
||||
"name": "Evening Mood",
|
||||
"icon": "🌅",
|
||||
"actions": {
|
||||
"light": "on",
|
||||
"mode": "simulation",
|
||||
"schema": "schema_02.csv",
|
||||
"devices": [
|
||||
{
|
||||
"id": "matter-001",
|
||||
"state": "on"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "scene-2",
|
||||
"name": "Night Mode",
|
||||
"icon": "🌙",
|
||||
"actions": {
|
||||
"light": "on",
|
||||
"mode": "night"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Scene Object:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|--------|------------------------------------|
|
||||
| id | string | Unique scene identifier |
|
||||
| name | string | Scene name |
|
||||
| icon | string | Emoji icon for the scene |
|
||||
| actions | object | Actions to execute (see below) |
|
||||
|
||||
**Actions Object:**
|
||||
|
||||
| Field | Type | Optional | Description |
|
||||
|---------|--------|----------|------------------------------------------|
|
||||
| light | string | Yes | `"on"` or `"off"` |
|
||||
| mode | string | Yes | `"day"`, `"night"`, or `"simulation"` |
|
||||
| schema | string | Yes | Schema filename (e.g., `schema_01.csv`) |
|
||||
| devices | array | Yes | Array of device actions (see below) |
|
||||
|
||||
**Device Action Object:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|--------|----------------------------------|
|
||||
| id | string | Device ID |
|
||||
| state | string | `"on"` or `"off"` |
|
||||
|
||||
---
|
||||
|
||||
#### Create/Update Scene
|
||||
|
||||
Creates a new scene or updates an existing one.
|
||||
|
||||
- **URL:** `/api/scenes`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "scene-1",
|
||||
"name": "Evening Mood",
|
||||
"icon": "🌅",
|
||||
"actions": {
|
||||
"light": "on",
|
||||
"mode": "simulation",
|
||||
"schema": "schema_02.csv",
|
||||
"devices": [
|
||||
{
|
||||
"id": "matter-001",
|
||||
"state": "on"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Delete Scene
|
||||
|
||||
Deletes a scene.
|
||||
|
||||
- **URL:** `/api/scenes`
|
||||
- **Method:** `DELETE`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "scene-1"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|--------|----------|---------------|
|
||||
| id | string | Yes | Scene ID |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
#### Activate Scene
|
||||
|
||||
Executes all actions of a scene.
|
||||
|
||||
- **URL:** `/api/scenes/activate`
|
||||
- **Method:** `POST`
|
||||
- **Content-Type:** `application/json`
|
||||
- **Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "scene-1"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|--------|----------|---------------|
|
||||
| id | string | Yes | Scene ID |
|
||||
|
||||
- **Response:** `200 OK` on success
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
### Connection
|
||||
|
||||
- **URL:** `ws://{host}/ws` or `wss://{host}/ws`
|
||||
- **Protocol:** JSON messages
|
||||
|
||||
The WebSocket connection is used for real-time status updates. The client should reconnect automatically if the connection is lost (recommended: 3 second delay).
|
||||
|
||||
---
|
||||
|
||||
### Client to Server Messages
|
||||
|
||||
#### Request Status
|
||||
|
||||
Requests the current system status.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "getStatus"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Server to Client Messages
|
||||
|
||||
#### Status Update
|
||||
|
||||
Sent in response to `getStatus` or when status changes.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "status",
|
||||
"on": true,
|
||||
"mode": "simulation",
|
||||
"schema": "schema_01.csv",
|
||||
"color": {
|
||||
"r": 255,
|
||||
"g": 240,
|
||||
"b": 220
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------|---------|--------------------------------------|
|
||||
| type | string | Always `"status"` |
|
||||
| on | boolean | Light power state |
|
||||
| mode | string | Current mode (day/night/simulation) |
|
||||
| schema | string | Active schema filename |
|
||||
| color | object | Current RGB color (optional) |
|
||||
|
||||
---
|
||||
|
||||
#### Color Update
|
||||
|
||||
Sent when the current color changes (during simulation).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "color",
|
||||
"r": 255,
|
||||
"g": 200,
|
||||
"b": 150
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|--------|--------------------------|
|
||||
| type | string | Always `"color"` |
|
||||
| r | number | Red value (0-255) |
|
||||
| g | number | Green value (0-255) |
|
||||
| b | number | Blue value (0-255) |
|
||||
|
||||
---
|
||||
|
||||
#### WiFi Status Update
|
||||
|
||||
Sent when WiFi connection status changes.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "wifi",
|
||||
"connected": true,
|
||||
"ip": "192.168.1.100",
|
||||
"rssi": -45
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|---------|---------------------------------------|
|
||||
| type | string | Always `"wifi"` |
|
||||
| connected | boolean | Whether connected to a network |
|
||||
| ip | string | Assigned IP address (if connected) |
|
||||
| rssi | number | Signal strength in dBm (if connected) |
|
||||
|
||||
---
|
||||
|
||||
## Static Files
|
||||
|
||||
The web interface files should be served from the `/storage/www/` directory:
|
||||
|
||||
| Path | Description |
|
||||
|----------------------|------------------------------------------------|
|
||||
| `/` | Serves `index.html` (or `captive.html` in AP mode) |
|
||||
| `/index.html` | Main HTML file (full interface) |
|
||||
| `/captive.html` | Captive portal (WiFi setup only) |
|
||||
| `/css/shared.css` | Shared styles for all pages |
|
||||
| `/css/index.css` | Styles for main interface |
|
||||
| `/css/captive.css` | Styles for captive portal |
|
||||
| `/js/wifi-shared.js` | Shared WiFi configuration logic |
|
||||
| `/js/*.js` | JavaScript modules |
|
||||
|
||||
---
|
||||
|
||||
## Captive Portal
|
||||
|
||||
When the ESP32 is in Access Point (AP) mode (no WiFi configured or connection failed), it should serve the captive portal:
|
||||
|
||||
### Behavior
|
||||
|
||||
1. **AP Mode Activation:**
|
||||
- ESP32 creates an access point (e.g., "marklin-setup")
|
||||
- DNS server redirects all requests to the ESP32's IP (captive portal detection)
|
||||
|
||||
2. **Captive Portal Detection:**
|
||||
- Respond to common captive portal detection URLs:
|
||||
- `/generate_204` (Android)
|
||||
- `/hotspot-detect.html` (Apple)
|
||||
- `/connecttest.txt` (Windows)
|
||||
- Return redirect or serve `captive.html`
|
||||
|
||||
3. **Serving Files in AP Mode:**
|
||||
- `/` → `captive.html`
|
||||
- `/captive.html` → Captive portal page
|
||||
- `/js/wifi-shared.js` → WiFi functions
|
||||
- API endpoints remain the same (`/api/wifi/*`)
|
||||
|
||||
4. **After Successful Configuration:**
|
||||
- ESP32 attempts to connect to the configured network
|
||||
- If successful, switch to Station mode and serve `index.html`
|
||||
- If failed, remain in AP mode
|
||||
|
||||
### Recommended AP Settings
|
||||
|
||||
| Setting | Value |
|
||||
|---------------|--------------------------|
|
||||
| SSID | `marklin-setup` |
|
||||
| Password | None (open) or `marklin` |
|
||||
| IP Address | `192.168.4.1` |
|
||||
| Gateway | `192.168.4.1` |
|
||||
| Subnet | `255.255.255.0` |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
All endpoints should return appropriate HTTP status codes:
|
||||
|
||||
| Code | Description |
|
||||
|------|---------------------------------------|
|
||||
| 200 | Success |
|
||||
| 400 | Bad Request (invalid input) |
|
||||
| 404 | Not Found (resource doesn't exist) |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
Error responses should include a JSON body with an error message:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Description of what went wrong"
|
||||
}
|
||||
```
|
||||
209
firmware/README-captive.md
Normal file
209
firmware/README-captive.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Captive Portal Implementation Guide
|
||||
|
||||
This document describes how to implement the captive portal functionality on the ESP32 side to work with `captive.html`.
|
||||
|
||||
## Overview
|
||||
|
||||
When the ESP32 has no WiFi credentials stored (or connection fails), it should start in Access Point (AP) mode and serve a captive portal that allows users to configure WiFi settings.
|
||||
|
||||
## How Captive Portal Detection Works
|
||||
|
||||
Operating systems automatically send HTTP requests to known URLs to check for internet connectivity:
|
||||
|
||||
| OS | Detection URL | Expected Response |
|
||||
|---|---|---|
|
||||
| **iOS/macOS** | `http://captive.apple.com/hotspot-detect.html` | `<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>` |
|
||||
| **Android** | `http://connectivitycheck.gstatic.com/generate_204` | HTTP 204 No Content |
|
||||
| **Windows** | `http://www.msftconnecttest.com/connecttest.txt` | `Microsoft Connect Test` |
|
||||
|
||||
If the response doesn't match, the OS assumes there's a captive portal and opens a browser.
|
||||
|
||||
## ESP32 Implementation Steps
|
||||
|
||||
### 1. Start Access Point Mode
|
||||
|
||||
```c
|
||||
wifi_config_t ap_config = {
|
||||
.ap = {
|
||||
.ssid = "SystemControl-Setup",
|
||||
.ssid_len = 0,
|
||||
.password = "", // Open network for easy access
|
||||
.max_connection = 4,
|
||||
.authmode = WIFI_AUTH_OPEN
|
||||
}
|
||||
};
|
||||
esp_wifi_set_mode(WIFI_MODE_AP);
|
||||
esp_wifi_set_config(WIFI_IF_AP, &ap_config);
|
||||
esp_wifi_start();
|
||||
```
|
||||
|
||||
### 2. Start DNS Server (DNS Hijacking)
|
||||
|
||||
Redirect ALL DNS queries to the ESP32's IP address:
|
||||
|
||||
```c
|
||||
// Simplified example - use a proper DNS server component
|
||||
void dns_server_task(void *pvParameters) {
|
||||
// Listen on UDP port 53
|
||||
// For any DNS query, respond with ESP32's AP IP (e.g., 192.168.4.1)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure HTTP Server with Redirects
|
||||
|
||||
```c
|
||||
// Handler for captive portal detection URLs
|
||||
esp_err_t captive_redirect_handler(httpd_req_t *req) {
|
||||
httpd_resp_set_status(req, "302 Found");
|
||||
httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/captive.html");
|
||||
httpd_resp_send(req, NULL, 0);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Register handlers for detection URLs
|
||||
httpd_uri_t apple_detect = {
|
||||
.uri = "/hotspot-detect.html",
|
||||
.method = HTTP_GET,
|
||||
.handler = captive_redirect_handler
|
||||
};
|
||||
|
||||
httpd_uri_t android_detect = {
|
||||
.uri = "/generate_204",
|
||||
.method = HTTP_GET,
|
||||
.handler = captive_redirect_handler
|
||||
};
|
||||
|
||||
// Catch-all for any unknown paths
|
||||
httpd_uri_t catch_all = {
|
||||
.uri = "/*",
|
||||
.method = HTTP_GET,
|
||||
.handler = captive_redirect_handler
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Serve Static Files
|
||||
|
||||
Serve the captive portal files from SPIFFS/LittleFS:
|
||||
|
||||
- `/captive.html` - Main captive portal page
|
||||
- `/favicon.svg` - Favicon
|
||||
- `/css/shared.css` - Shared styles
|
||||
- `/css/captive.css` - Captive-specific styles
|
||||
- `/js/i18n.js` - Internationalization
|
||||
- `/js/wifi-shared.js` - WiFi configuration logic
|
||||
|
||||
### 5. Implement WiFi Configuration API
|
||||
|
||||
```c
|
||||
// POST /api/wifi/config
|
||||
// Body: { "ssid": "NetworkName", "password": "SecretPassword" }
|
||||
esp_err_t wifi_config_handler(httpd_req_t *req) {
|
||||
// 1. Parse JSON body
|
||||
// 2. Store credentials in NVS
|
||||
// 3. Send success response
|
||||
// 4. Schedule restart/reconnect
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// GET /api/wifi/scan
|
||||
// Returns: [{ "ssid": "Network1", "rssi": -45 }, ...]
|
||||
esp_err_t wifi_scan_handler(httpd_req_t *req) {
|
||||
// 1. Perform WiFi scan
|
||||
// 2. Return JSON array of networks
|
||||
return ESP_OK;
|
||||
}
|
||||
```
|
||||
|
||||
## Flow After User Submits WiFi Credentials
|
||||
|
||||
```
|
||||
1. User enters SSID + Password, clicks "Connect"
|
||||
↓
|
||||
2. Frontend sends POST /api/wifi/config
|
||||
↓
|
||||
3. ESP32 stores credentials in NVS (Non-Volatile Storage)
|
||||
↓
|
||||
4. ESP32 sends HTTP 200 OK response
|
||||
↓
|
||||
5. Frontend shows countdown (10 seconds)
|
||||
↓
|
||||
6. ESP32 stops AP mode
|
||||
↓
|
||||
7. ESP32 connects to configured WiFi
|
||||
↓
|
||||
8. ESP32 gets new IP from router (e.g., 192.168.1.42)
|
||||
↓
|
||||
9. User connects phone/PC to normal WiFi
|
||||
↓
|
||||
10. User accesses ESP32 via new IP or mDNS (e.g., http://system-control.local)
|
||||
```
|
||||
|
||||
## Recommended: mDNS Support
|
||||
|
||||
Register an mDNS hostname so users can access the device without knowing the IP:
|
||||
|
||||
```c
|
||||
mdns_init();
|
||||
mdns_hostname_set("system-control");
|
||||
mdns_instance_name_set("System Control");
|
||||
```
|
||||
|
||||
Then the device is accessible at: `http://system-control.local`
|
||||
|
||||
## Error Handling / Fallback
|
||||
|
||||
If WiFi connection fails after credentials are saved:
|
||||
|
||||
1. Wait for connection timeout (e.g., 30 seconds)
|
||||
2. If connection fails, restart in AP mode
|
||||
3. Show error message on captive portal
|
||||
4. Allow user to re-enter credentials
|
||||
|
||||
```c
|
||||
// Pseudo-code
|
||||
if (wifi_connect_timeout()) {
|
||||
nvs_erase_key("wifi_ssid");
|
||||
nvs_erase_key("wifi_password");
|
||||
esp_restart(); // Will boot into AP mode again
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints Summary
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/wifi/scan` | Scan for available networks |
|
||||
| POST | `/api/wifi/config` | Save WiFi credentials |
|
||||
| GET | `/api/wifi/status` | Get current connection status |
|
||||
|
||||
### Request/Response Examples
|
||||
|
||||
**GET /api/wifi/scan**
|
||||
```json
|
||||
[
|
||||
{ "ssid": "HomeNetwork", "rssi": -45, "secure": true },
|
||||
{ "ssid": "GuestWiFi", "rssi": -67, "secure": false }
|
||||
]
|
||||
```
|
||||
|
||||
**POST /api/wifi/config**
|
||||
```json
|
||||
{ "ssid": "HomeNetwork", "password": "MySecretPassword" }
|
||||
```
|
||||
|
||||
**GET /api/wifi/status**
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
"ssid": "HomeNetwork",
|
||||
"ip": "192.168.1.42",
|
||||
"rssi": -52
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Open AP**: The setup AP is intentionally open for easy access. Keep setup time short.
|
||||
2. **HTTPS**: Consider using HTTPS for the main interface (after WiFi setup).
|
||||
3. **Timeout**: Auto-disable AP mode after successful connection.
|
||||
4. **Button Reset**: Implement a physical button to reset WiFi credentials and re-enter AP mode.
|
||||
138
firmware/storage/www/captive.html
Normal file
138
firmware/storage/www/captive.html
Normal file
@@ -0,0 +1,138 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<title data-i18n="captive.title">System Control - WLAN Setup</title>
|
||||
<link rel="stylesheet" href="css/shared.css">
|
||||
<link rel="stylesheet" href="css/captive.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="header-controls captive-header">
|
||||
<button class="lang-toggle" onclick="toggleLanguage()" aria-label="Sprache wechseln">
|
||||
<span class="lang-flag" id="lang-flag">🇩🇪</span>
|
||||
<span id="lang-label">DE</span>
|
||||
</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Theme wechseln">
|
||||
<span class="theme-toggle-icon" id="theme-icon">🌙</span>
|
||||
<span id="theme-label">Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚂 System Control</h1>
|
||||
<p data-i18n="captive.subtitle">WLAN-Einrichtung</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div id="scan-section">
|
||||
<button class="btn btn-secondary" onclick="scanNetworks()" data-i18n="captive.scan">
|
||||
📡 Netzwerke suchen
|
||||
</button>
|
||||
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p data-i18n="captive.scanning">Suche nach Netzwerken...</p>
|
||||
</div>
|
||||
|
||||
<div id="network-list" class="network-list" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"><span data-i18n="captive.or.manual">oder manuell eingeben</span></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ssid" data-i18n="wifi.ssid">WLAN-Name (SSID)</label>
|
||||
<input type="text" id="ssid" data-i18n-placeholder="wifi.ssid.placeholder"
|
||||
placeholder="Netzwerkname eingeben">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" data-i18n="wifi.password.short">Passwort</label>
|
||||
<div class="password-toggle">
|
||||
<input type="password" id="password" data-i18n-placeholder="captive.password.placeholder"
|
||||
placeholder="WLAN-Passwort">
|
||||
<button type="button" onclick="togglePassword()" id="password-btn">👁️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="saveWifi()" data-i18n="captive.connect">
|
||||
💾 Verbinden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="wifi-status" class="status"></div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>ℹ️ <span data-i18n="captive.note.title">Hinweis:</span></strong>
|
||||
<span data-i18n="captive.note.text">Nach dem Speichern verbindet sich das Gerät mit dem gewählten
|
||||
Netzwerk.
|
||||
Diese Seite wird dann nicht mehr erreichbar sein. Verbinden Sie sich mit Ihrem normalen WLAN,
|
||||
um auf das Gerät zuzugreifen.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/wifi-shared.js"></script>
|
||||
<script>
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const icon = document.getElementById('theme-icon');
|
||||
const label = document.getElementById('theme-label');
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||
|
||||
if (theme === 'light') {
|
||||
icon.textContent = '☀️';
|
||||
label.textContent = 'Light';
|
||||
if (metaTheme) metaTheme.content = '#faf8f5';
|
||||
} else {
|
||||
icon.textContent = '🌙';
|
||||
label.textContent = 'Dark';
|
||||
if (metaTheme) metaTheme.content = '#1a1a2e';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
initI18n();
|
||||
// Auto-scan on load
|
||||
setTimeout(scanNetworks, 500);
|
||||
});
|
||||
|
||||
// Toggle password visibility
|
||||
function togglePassword() {
|
||||
const input = document.getElementById('password');
|
||||
const btn = document.getElementById('password-btn');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
btn.textContent = '🙈';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
btn.textContent = '👁️';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
201
firmware/storage/www/css/captive.css
Normal file
201
firmware/storage/www/css/captive.css
Normal file
@@ -0,0 +1,201 @@
|
||||
/* Captive Portal CSS - WiFi setup specific styles */
|
||||
/* Base styles are in shared.css */
|
||||
|
||||
body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Theme Toggle - Absolute positioned */
|
||||
/* Handled by .captive-header in shared.css */
|
||||
|
||||
/* Card - Larger padding for captive */
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px var(--shadow);
|
||||
}
|
||||
|
||||
/* Form Group - More spacing */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Inputs - Thicker border for captive */
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
padding: 14px 16px;
|
||||
border-width: 2px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Buttons - Full width for captive */
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Status - Centered text */
|
||||
.status {
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Network List */
|
||||
.network-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.network-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.network-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.network-item:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.network-item.selected {
|
||||
background: var(--accent);
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.network-name {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.network-signal {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.signal-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Password Toggle */
|
||||
.password-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-toggle input {
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.password-toggle button {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.info-box {
|
||||
background: var(--accent);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
margin-top: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Spinner - Smaller for captive */
|
||||
.spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 20px;
|
||||
}
|
||||
1201
firmware/storage/www/css/index.css
Normal file
1201
firmware/storage/www/css/index.css
Normal file
File diff suppressed because it is too large
Load Diff
300
firmware/storage/www/css/shared.css
Normal file
300
firmware/storage/www/css/shared.css
Normal file
@@ -0,0 +1,300 @@
|
||||
/* Shared CSS - Base styles for all pages */
|
||||
|
||||
/* CSS Variables - Dark Mode (default) */
|
||||
:root {
|
||||
--bg-color: #1a1a2e;
|
||||
--card-bg: #16213e;
|
||||
--accent: #0f3460;
|
||||
--text: #eaeaea;
|
||||
--text-muted: #a0a0a0;
|
||||
--success: #00d26a;
|
||||
--error: #ff6b6b;
|
||||
--border: #2a2a4a;
|
||||
--input-bg: #1a1a2e;
|
||||
--shadow: rgba(0, 0, 0, 0.3);
|
||||
--primary: #c41e3a;
|
||||
}
|
||||
|
||||
/* CSS Variables - Light Mode */
|
||||
[data-theme="light"] {
|
||||
--bg-color: #faf8f5;
|
||||
--card-bg: #ffffff;
|
||||
--accent: #fef2f2;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--success: #c41e3a;
|
||||
--error: #dc2626;
|
||||
--border: #e5d9d0;
|
||||
--input-bg: #ffffff;
|
||||
--shadow: rgba(196, 30, 58, 0.1);
|
||||
--primary: #c41e3a;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg-color);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: var(--success);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, opacity 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-theme="light"] .btn-primary:hover {
|
||||
background: #a31830;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Status Messages */
|
||||
.status {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
display: block;
|
||||
background: rgba(0, 210, 106, 0.15);
|
||||
border: 1px solid var(--success);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
display: block;
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status.info {
|
||||
display: block;
|
||||
background: rgba(15, 52, 96, 0.5);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--card-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.theme-toggle-icon {
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.theme-toggle-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Language Toggle */
|
||||
.lang-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--card-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.lang-toggle:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.lang-flag {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Header Controls */
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.captive-header {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
[data-theme="light"] .header h1 {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--success);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 12px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for notched phones */
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
1
firmware/storage/www/favicon.svg
Normal file
1
firmware/storage/www/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🚂</text></svg>
|
||||
|
After Width: | Height: | Size: 109 B |
429
firmware/storage/www/index.html
Normal file
429
firmware/storage/www/index.html
Normal file
@@ -0,0 +1,429 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<title data-i18n="page.title">System Control</title>
|
||||
<link rel="stylesheet" href="css/shared.css">
|
||||
<link rel="stylesheet" href="css/index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚂 System Control</h1>
|
||||
<div class="header-controls">
|
||||
<button class="lang-toggle" onclick="toggleLanguage()" aria-label="Sprache wechseln">
|
||||
<span class="lang-flag" id="lang-flag">🇩🇪</span>
|
||||
<span class="lang-label" id="lang-label">DE</span>
|
||||
</button>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Theme wechseln">
|
||||
<span class="theme-toggle-icon" id="theme-icon">🌙</span>
|
||||
<span class="theme-toggle-label" id="theme-label">Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('control')" data-i18n="tab.control">🎛️ Bedienung</button>
|
||||
<button class="tab" onclick="switchTab('config')" data-i18n="tab.config">⚙️ Konfiguration</button>
|
||||
</div>
|
||||
|
||||
<!-- Bedienung Tab -->
|
||||
<div id="tab-control" class="tab-content active">
|
||||
<div class="card">
|
||||
<h2 data-i18n="control.light.title">Lichtsteuerung</h2>
|
||||
|
||||
<div class="control-section">
|
||||
<h3 data-i18n="control.light.onoff">Ein/Aus</h3>
|
||||
<div class="control-group">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label" data-i18n="control.light.light">Licht</span>
|
||||
<button class="toggle-switch" id="light-toggle" onclick="toggleLight()">
|
||||
<span class="toggle-state" id="light-state" data-i18n="common.off">AUS</span>
|
||||
<span class="toggle-icon" id="light-icon">💡</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="light-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h3 data-i18n="control.mode.title">Betriebsmodus</h3>
|
||||
<div class="mode-selector">
|
||||
<button class="mode-btn" id="mode-day" onclick="setMode('day')">
|
||||
<span class="mode-icon">☀️</span>
|
||||
<span class="mode-name" data-i18n="mode.day">Tag</span>
|
||||
</button>
|
||||
<button class="mode-btn" id="mode-night" onclick="setMode('night')">
|
||||
<span class="mode-icon">🌙</span>
|
||||
<span class="mode-name" data-i18n="mode.night">Nacht</span>
|
||||
</button>
|
||||
<button class="mode-btn active" id="mode-simulation" onclick="setMode('simulation')">
|
||||
<span class="mode-icon">🔄</span>
|
||||
<span class="mode-name" data-i18n="mode.simulation">Simulation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="simulation-options" class="simulation-options">
|
||||
<div class="form-group">
|
||||
<label for="active-schema" data-i18n="control.schema.active">Aktives Schema</label>
|
||||
<select id="active-schema" onchange="setActiveSchema()">
|
||||
<option value="schema_01.csv" data-i18n="schema.name.1">Schema 1 (Standard)</option>
|
||||
<option value="schema_02.csv" data-i18n="schema.name.2">Schema 2 (Warm)</option>
|
||||
<option value="schema_03.csv" data-i18n="schema.name.3">Schema 3 (Natur)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mode-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h3 data-i18n="control.status.title">Aktueller Status</h3>
|
||||
<div class="status-display">
|
||||
<div class="status-item">
|
||||
<span class="status-label" data-i18n="control.status.mode">Modus</span>
|
||||
<span class="status-value" id="current-mode" data-i18n="mode.simulation">Simulation</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label" data-i18n="control.status.schema">Schema</span>
|
||||
<span class="status-value" id="current-schema">Schema 1</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label" data-i18n="control.status.color">Aktuelle Farbe</span>
|
||||
<div class="current-color-preview" id="current-color"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Szenen Card -->
|
||||
<div class="card" id="scenes-control-card">
|
||||
<h2 data-i18n="scenes.title">Szenen</h2>
|
||||
<div id="scenes-control-list" class="scenes-grid">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-scenes-control">
|
||||
<span class="empty-icon">🎬</span>
|
||||
<p data-i18n="scenes.empty">Keine Szenen definiert</p>
|
||||
<p class="empty-hint" data-i18n="scenes.empty.hint">Erstelle Szenen unter Konfiguration</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scenes-control-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<!-- Externe Geräte Card -->
|
||||
<div class="card" id="devices-control-card">
|
||||
<h2 data-i18n="devices.external">Externe Geräte</h2>
|
||||
<div id="devices-control-list" class="devices-control-grid">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-devices-control">
|
||||
<span class="empty-icon">🔗</span>
|
||||
<p data-i18n="devices.control.empty">Keine Geräte hinzugefügt</p>
|
||||
<p class="empty-hint" data-i18n="devices.control.empty.hint">Füge Geräte unter Konfiguration
|
||||
hinzu</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Konfiguration Tab -->
|
||||
<div id="tab-config" class="tab-content">
|
||||
<div class="sub-tabs">
|
||||
<button class="sub-tab active" onclick="switchSubTab('wifi')" data-i18n="subtab.wifi">📶 WLAN</button>
|
||||
<button class="sub-tab" onclick="switchSubTab('schema')" data-i18n="subtab.schema">💡 Schema</button>
|
||||
<button class="sub-tab" onclick="switchSubTab('devices')" data-i18n="subtab.devices">🔗 Geräte</button>
|
||||
<button class="sub-tab" onclick="switchSubTab('scenes')" data-i18n="subtab.scenes">🎬 Szenen</button>
|
||||
</div>
|
||||
|
||||
<!-- WLAN Sub-Tab -->
|
||||
<div id="subtab-wifi" class="sub-tab-content active">
|
||||
<div class="card">
|
||||
<h2 data-i18n="wifi.config.title">WLAN Konfiguration</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ssid" data-i18n="wifi.ssid">WLAN Name (SSID)</label>
|
||||
<input type="text" id="ssid" data-i18n-placeholder="wifi.ssid.placeholder"
|
||||
placeholder="Netzwerkname eingeben" autocomplete="off" autocapitalize="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" data-i18n="wifi.password">WLAN Passwort</label>
|
||||
<input type="password" id="password" data-i18n-placeholder="wifi.password.placeholder"
|
||||
placeholder="Passwort eingeben" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="wifi.available">Verfügbare Netzwerke</label>
|
||||
<select id="available-networks"
|
||||
onchange="if(this.value) document.getElementById('ssid').value = this.value">
|
||||
<option value="" data-i18n="wifi.scan.hint">Nach Netzwerken suchen...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="scanNetworks()" data-i18n="btn.scan">🔍
|
||||
Suchen</button>
|
||||
<button class="btn btn-primary" onclick="saveWifi()" data-i18n="btn.save">💾 Speichern</button>
|
||||
</div>
|
||||
|
||||
<div id="wifi-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="wifi.status.title">Verbindungsstatus</h2>
|
||||
<div id="connection-info">
|
||||
<p><strong data-i18n="wifi.status.status">Status:</strong> <span id="conn-status"
|
||||
data-i18n="common.loading">Wird geladen...</span></p>
|
||||
<p><strong data-i18n="wifi.status.ip">IP-Adresse:</strong> <span id="conn-ip">-</span></p>
|
||||
<p><strong data-i18n="wifi.status.signal">Signal:</strong> <span id="conn-rssi">-</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schema Sub-Tab -->
|
||||
<div id="subtab-schema" class="sub-tab-content">
|
||||
<div class="card">
|
||||
<h2 data-i18n="schema.editor.title">Licht-Schema Editor</h2>
|
||||
|
||||
<div class="schema-controls">
|
||||
<div class="form-group">
|
||||
<label for="schema-select" data-i18n="schema.file">Schema-Datei</label>
|
||||
<select id="schema-select" onchange="loadSchema()">
|
||||
<option value="schema_01.csv" data-i18n="schema.name.1">Schema 1 (Standard)</option>
|
||||
<option value="schema_02.csv" data-i18n="schema.name.2">Schema 2 (Warm)</option>
|
||||
<option value="schema_03.csv" data-i18n="schema.name.3">Schema 3 (Natur)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick="loadSchema()" data-i18n="btn.load">🔄 Laden</button>
|
||||
<button class="btn btn-primary" onclick="saveSchema()" data-i18n="btn.save">💾
|
||||
Speichern</button>
|
||||
</div>
|
||||
|
||||
<div id="schema-status" class="status"></div>
|
||||
|
||||
<div id="schema-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p data-i18n="schema.loading">Schema wird geladen...</p>
|
||||
</div>
|
||||
|
||||
<div class="value-header">
|
||||
<span data-i18n="schema.header.time">Zeit</span>
|
||||
<span data-i18n="schema.header.color">Farbe</span>
|
||||
<span>R</span>
|
||||
<span>G</span>
|
||||
<span>B</span>
|
||||
<span>V1</span>
|
||||
<span>V2</span>
|
||||
<span>V3</span>
|
||||
</div>
|
||||
|
||||
<div id="schema-grid" class="time-grid">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Szenen Sub-Tab -->
|
||||
<div id="subtab-scenes" class="sub-tab-content">
|
||||
<div class="card">
|
||||
<h2 data-i18n="scenes.manage.title">Szenen verwalten</h2>
|
||||
<p class="card-description" data-i18n="scenes.manage.desc">Erstelle und bearbeite Szenen für
|
||||
schnellen Zugriff</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="openSceneModal()" data-i18n="btn.new.scene">➕ Neue
|
||||
Szene</button>
|
||||
</div>
|
||||
|
||||
<div id="scenes-config-list" class="scenes-config-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-scenes-config">
|
||||
<span class="empty-icon">🎬</span>
|
||||
<p data-i18n="scenes.config.empty">Keine Szenen erstellt</p>
|
||||
<p class="empty-hint" data-i18n="scenes.config.empty.hint">Klicke auf "Neue Szene" um eine
|
||||
Szene zu erstellen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scenes-status" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geräte Sub-Tab -->
|
||||
<div id="subtab-devices" class="sub-tab-content">
|
||||
<div class="card">
|
||||
<h2 data-i18n="devices.new.title">Neue Geräte</h2>
|
||||
<p class="card-description" data-i18n="devices.new.desc">Unprovisionierte Matter-Geräte in der Nähe
|
||||
</p>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="scanDevices()" data-i18n="btn.scan.devices">🔍 Geräte
|
||||
suchen</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p data-i18n="devices.searching">Suche nach Geräten...</p>
|
||||
</div>
|
||||
|
||||
<div id="unpaired-devices" class="device-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-unpaired-devices">
|
||||
<span class="empty-icon">📡</span>
|
||||
<p data-i18n="devices.unpaired.empty">Keine neuen Geräte gefunden</p>
|
||||
<p class="empty-hint" data-i18n="devices.unpaired.empty.hint">Drücke "Geräte suchen" um nach
|
||||
Matter-Geräten zu suchen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="devices-status" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 data-i18n="devices.paired.title">Zugeordnete Geräte</h2>
|
||||
<p class="card-description" data-i18n="devices.paired.desc">Bereits hinzugefügte externe Geräte</p>
|
||||
|
||||
<div id="paired-devices" class="device-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state" id="no-paired-devices">
|
||||
<span class="empty-icon">📦</span>
|
||||
<p data-i18n="devices.paired.empty">Keine Geräte hinzugefügt</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker Modal -->
|
||||
<div id="color-modal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3><span data-i18n="modal.color.title">Farbe wählen</span> - <span id="modal-time"></span></h3>
|
||||
|
||||
<div class="color-picker-container">
|
||||
<div class="color-slider">
|
||||
<label style="color: #f66;">R</label>
|
||||
<input type="range" id="rangeR" min="0" max="255" value="255" oninput="updateModalColor()">
|
||||
<span id="valR" class="value">255</span>
|
||||
</div>
|
||||
<div class="color-slider">
|
||||
<label style="color: #6f6;">G</label>
|
||||
<input type="range" id="rangeG" min="0" max="255" value="255" oninput="updateModalColor()">
|
||||
<span id="valG" class="value">255</span>
|
||||
</div>
|
||||
<div class="color-slider">
|
||||
<label style="color: #66f;">B</label>
|
||||
<input type="range" id="rangeB" min="0" max="255" value="255" oninput="updateModalColor()">
|
||||
<span id="valB" class="value">255</span>
|
||||
</div>
|
||||
|
||||
<div id="preview-large" class="preview-large"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-secondary" onclick="closeColorModal()" data-i18n="btn.cancel">Abbrechen</button>
|
||||
<button class="btn btn-primary" onclick="applyColor()" data-i18n="btn.apply">Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene Modal -->
|
||||
<div id="scene-modal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3 id="scene-modal-title" data-i18n="modal.scene.new">Neue Szene erstellen</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="scene-name" data-i18n="scene.name">Name</label>
|
||||
<input type="text" id="scene-name" data-i18n-placeholder="scene.name.placeholder"
|
||||
placeholder="z.B. Abendstimmung" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="scene.icon">Icon auswählen</label>
|
||||
<div class="icon-selector">
|
||||
<button type="button" class="icon-btn active" data-icon="🌅"
|
||||
onclick="selectSceneIcon('🌅')">🌅</button>
|
||||
<button type="button" class="icon-btn" data-icon="🌙" onclick="selectSceneIcon('🌙')">🌙</button>
|
||||
<button type="button" class="icon-btn" data-icon="☀️" onclick="selectSceneIcon('☀️')">☀️</button>
|
||||
<button type="button" class="icon-btn" data-icon="🎬" onclick="selectSceneIcon('🎬')">🎬</button>
|
||||
<button type="button" class="icon-btn" data-icon="💤" onclick="selectSceneIcon('💤')">💤</button>
|
||||
<button type="button" class="icon-btn" data-icon="🎉" onclick="selectSceneIcon('🎉')">🎉</button>
|
||||
<button type="button" class="icon-btn" data-icon="🍿" onclick="selectSceneIcon('🍿')">🍿</button>
|
||||
<button type="button" class="icon-btn" data-icon="📚" onclick="selectSceneIcon('📚')">📚</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="scene.actions">Aktionen</label>
|
||||
<div class="scene-actions-editor">
|
||||
<div class="scene-action-row">
|
||||
<label>
|
||||
<input type="checkbox" id="scene-action-light" checked>
|
||||
<span data-i18n="scene.action.light">Licht Ein/Aus</span>
|
||||
</label>
|
||||
<select id="scene-light-state">
|
||||
<option value="on" data-i18n="scene.light.on">Einschalten</option>
|
||||
<option value="off" data-i18n="scene.light.off">Ausschalten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="scene-action-row">
|
||||
<label>
|
||||
<input type="checkbox" id="scene-action-mode">
|
||||
<span data-i18n="scene.action.mode">Modus setzen</span>
|
||||
</label>
|
||||
<select id="scene-mode-value">
|
||||
<option value="day" data-i18n="mode.day">Tag</option>
|
||||
<option value="night" data-i18n="mode.night">Nacht</option>
|
||||
<option value="simulation" data-i18n="mode.simulation">Simulation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="scene-action-row">
|
||||
<label>
|
||||
<input type="checkbox" id="scene-action-schema">
|
||||
<span data-i18n="scene.action.schema">Schema wählen</span>
|
||||
</label>
|
||||
<select id="scene-schema-value">
|
||||
<option value="schema_01.csv">Schema 1</option>
|
||||
<option value="schema_02.csv">Schema 2</option>
|
||||
<option value="schema_03.csv">Schema 3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="devices.external">Externe Geräte</label>
|
||||
<div id="scene-devices-list" class="scene-devices-list">
|
||||
<!-- Wird dynamisch gefüllt -->
|
||||
<div class="empty-state small" id="no-scene-devices">
|
||||
<span class="empty-icon">🔗</span>
|
||||
<p data-i18n="devices.none.available">Keine Geräte verfügbar</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button class="btn btn-secondary" onclick="closeSceneModal()" data-i18n="btn.cancel">Abbrechen</button>
|
||||
<button class="btn btn-primary" onclick="saveScene()" data-i18n="btn.save">💾 Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript Modules -->
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/wifi-shared.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/websocket.js"></script>
|
||||
<script src="js/light.js"></script>
|
||||
<script src="js/scenes.js"></script>
|
||||
<script src="js/devices.js"></script>
|
||||
<script src="js/schema.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
60
firmware/storage/www/js/app.js
Normal file
60
firmware/storage/www/js/app.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Global variables
|
||||
let schemaData = [];
|
||||
let currentEditRow = null;
|
||||
let lightOn = false;
|
||||
let currentMode = 'simulation';
|
||||
let ws = null;
|
||||
let wsReconnectTimer = null;
|
||||
let pairedDevices = [];
|
||||
let scenes = [];
|
||||
let currentEditScene = null;
|
||||
let selectedSceneIcon = '🌅';
|
||||
|
||||
// Event listeners
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeColorModal();
|
||||
closeSceneModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('color-modal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
closeColorModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('scene-modal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
closeSceneModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent zoom on double-tap for iOS
|
||||
let lastTouchEnd = 0;
|
||||
document.addEventListener('touchend', (e) => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouchEnd <= 300) {
|
||||
e.preventDefault();
|
||||
}
|
||||
lastTouchEnd = now;
|
||||
}, false);
|
||||
|
||||
// Initialization
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initI18n();
|
||||
initTheme();
|
||||
initWebSocket();
|
||||
updateConnectionStatus();
|
||||
loadScenes();
|
||||
loadPairedDevices();
|
||||
// WiFi status polling (less frequent)
|
||||
setInterval(updateConnectionStatus, 30000);
|
||||
});
|
||||
|
||||
// Close WebSocket on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
231
firmware/storage/www/js/devices.js
Normal file
231
firmware/storage/www/js/devices.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// Device management
|
||||
function renderDevicesControl() {
|
||||
const list = document.getElementById('devices-control-list');
|
||||
const noDevices = document.getElementById('no-devices-control');
|
||||
|
||||
list.querySelectorAll('.device-control-item').forEach(el => el.remove());
|
||||
|
||||
if (pairedDevices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
noDevices.style.display = 'none';
|
||||
pairedDevices.forEach(device => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'device-control-item';
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
item.innerHTML = `
|
||||
<span class="device-control-icon">${icon}</span>
|
||||
<span class="device-control-name">${device.name}</span>
|
||||
${device.type === 'light' ? `<button class="toggle-switch small" onclick="toggleExternalDevice('${device.id}')"><span class="toggle-icon">💡</span></button>` : ''}
|
||||
`;
|
||||
list.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleExternalDevice(deviceId) {
|
||||
try {
|
||||
await fetch('/api/devices/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: deviceId })
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Demo: Gerät umgeschaltet');
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDevices() {
|
||||
const loading = document.getElementById('devices-loading');
|
||||
const unpairedList = document.getElementById('unpaired-devices');
|
||||
const noDevices = document.getElementById('no-unpaired-devices');
|
||||
|
||||
loading.classList.add('active');
|
||||
noDevices.style.display = 'none';
|
||||
|
||||
// Entferne vorherige Ergebnisse (außer empty-state)
|
||||
unpairedList.querySelectorAll('.device-item').forEach(el => el.remove());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/devices/scan');
|
||||
const devices = await response.json();
|
||||
|
||||
loading.classList.remove('active');
|
||||
|
||||
if (devices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
devices.forEach(device => {
|
||||
const item = createUnpairedDeviceItem(device);
|
||||
unpairedList.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
|
||||
showStatus('devices-status', t('devices.found', { count: devices.length }), 'success');
|
||||
} catch (error) {
|
||||
loading.classList.remove('active');
|
||||
// Demo data
|
||||
const demoDevices = [
|
||||
{ id: 'matter-001', type: 'light', name: 'Matter Lamp' },
|
||||
{ id: 'matter-002', type: 'sensor', name: 'Temperature Sensor' }
|
||||
];
|
||||
|
||||
demoDevices.forEach(device => {
|
||||
const item = createUnpairedDeviceItem(device);
|
||||
unpairedList.insertBefore(item, noDevices);
|
||||
});
|
||||
|
||||
showStatus('devices-status', `Demo: ${t('devices.found', { count: 2 })}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function createUnpairedDeviceItem(device) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'device-item unpaired';
|
||||
item.dataset.id = device.id;
|
||||
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
const unknownDevice = getCurrentLanguage() === 'en' ? 'Unknown Device' : 'Unbekanntes Gerät';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="device-info">
|
||||
<span class="device-icon">${icon}</span>
|
||||
<div class="device-details">
|
||||
<span class="device-name">${device.name || unknownDevice}</span>
|
||||
<span class="device-id">${device.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-small" onclick="pairDevice('${device.id}', '${device.name || unknownDevice}', '${device.type || 'unknown'}')">
|
||||
➕ ${t('btn.add')}
|
||||
</button>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async function pairDevice(id, name, type) {
|
||||
try {
|
||||
const response = await fetch('/api/devices/pair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('devices-status', t('devices.added', { name }), 'success');
|
||||
// Entferne aus unpaired Liste
|
||||
document.querySelector(`.device-item[data-id="${id}"]`)?.remove();
|
||||
// Lade paired Geräte neu
|
||||
loadPairedDevices();
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
showStatus('devices-status', `Demo: ${t('devices.added', { name })}`, 'success');
|
||||
document.querySelector(`.device-item.unpaired[data-id="${id}"]`)?.remove();
|
||||
|
||||
// Füge zu Demo-Liste hinzu
|
||||
pairedDevices.push({ id, name, type });
|
||||
renderPairedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPairedDevices() {
|
||||
try {
|
||||
const response = await fetch('/api/devices/paired');
|
||||
pairedDevices = await response.json();
|
||||
renderPairedDevices();
|
||||
} catch (error) {
|
||||
// Keep demo data
|
||||
renderPairedDevices();
|
||||
}
|
||||
}
|
||||
|
||||
function renderPairedDevices() {
|
||||
const list = document.getElementById('paired-devices');
|
||||
const noDevices = document.getElementById('no-paired-devices');
|
||||
|
||||
// Remove previous entries
|
||||
list.querySelectorAll('.device-item').forEach(el => el.remove());
|
||||
|
||||
if (pairedDevices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
noDevices.style.display = 'none';
|
||||
pairedDevices.forEach(device => {
|
||||
const item = createPairedDeviceItem(device);
|
||||
list.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
|
||||
// Also update the control page
|
||||
renderDevicesControl();
|
||||
}
|
||||
|
||||
function createPairedDeviceItem(device) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'device-item paired';
|
||||
item.dataset.id = device.id;
|
||||
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
const placeholder = getCurrentLanguage() === 'en' ? 'Device name' : 'Gerätename';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="device-info">
|
||||
<span class="device-icon">${icon}</span>
|
||||
<div class="device-details">
|
||||
<input type="text" class="device-name-input" value="${device.name}"
|
||||
onchange="updateDeviceName('${device.id}', this.value)"
|
||||
placeholder="${placeholder}">
|
||||
<span class="device-id">${device.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-small btn-danger" onclick="unpairDevice('${device.id}', '${device.name}')">
|
||||
🗑️
|
||||
</button>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async function updateDeviceName(id, newName) {
|
||||
try {
|
||||
const response = await fetch('/api/devices/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, name: newName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('devices-status', t('devices.name.updated'), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode - update locally
|
||||
const device = pairedDevices.find(d => d.id === id);
|
||||
if (device) device.name = newName;
|
||||
showStatus('devices-status', `Demo: ${t('devices.name.updated')}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async function unpairDevice(id, name) {
|
||||
if (!confirm(t('devices.confirm.remove', { name }))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/devices/unpair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('devices-status', t('devices.removed', { name }), 'success');
|
||||
loadPairedDevices();
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
pairedDevices = pairedDevices.filter(d => d.id !== id);
|
||||
renderPairedDevices();
|
||||
showStatus('devices-status', `Demo: ${t('devices.removed', { name })}`, 'success');
|
||||
}
|
||||
}
|
||||
445
firmware/storage/www/js/i18n.js
Normal file
445
firmware/storage/www/js/i18n.js
Normal file
@@ -0,0 +1,445 @@
|
||||
// Internationalization (i18n) - Language support
|
||||
// Supported languages: German (de), English (en)
|
||||
|
||||
const translations = {
|
||||
de: {
|
||||
// Page
|
||||
'page.title': 'System Control',
|
||||
|
||||
// Main Tabs
|
||||
'tab.control': '🎛️ Bedienung',
|
||||
'tab.config': '⚙️ Konfiguration',
|
||||
|
||||
// Sub Tabs
|
||||
'subtab.wifi': '📶 WLAN',
|
||||
'subtab.schema': '💡 Schema',
|
||||
'subtab.devices': '🔗 Geräte',
|
||||
'subtab.scenes': '🎬 Szenen',
|
||||
|
||||
// Light Control
|
||||
'control.light.title': 'Lichtsteuerung',
|
||||
'control.light.onoff': 'Ein/Aus',
|
||||
'control.light.light': 'Licht',
|
||||
'control.mode.title': 'Betriebsmodus',
|
||||
'control.schema.active': 'Aktives Schema',
|
||||
'control.status.title': 'Aktueller Status',
|
||||
'control.status.mode': 'Modus',
|
||||
'control.status.schema': 'Schema',
|
||||
'control.status.color': 'Aktuelle Farbe',
|
||||
|
||||
// Common
|
||||
'common.on': 'AN',
|
||||
'common.off': 'AUS',
|
||||
'common.loading': 'Wird geladen...',
|
||||
|
||||
// Modes
|
||||
'mode.day': 'Tag',
|
||||
'mode.night': 'Nacht',
|
||||
'mode.simulation': 'Simulation',
|
||||
|
||||
// Schema names
|
||||
'schema.name.1': 'Schema 1 (Standard)',
|
||||
'schema.name.2': 'Schema 2 (Warm)',
|
||||
'schema.name.3': 'Schema 3 (Natur)',
|
||||
|
||||
// Scenes
|
||||
'scenes.title': 'Szenen',
|
||||
'scenes.empty': 'Keine Szenen definiert',
|
||||
'scenes.empty.hint': 'Erstelle Szenen unter Konfiguration',
|
||||
'scenes.manage.title': 'Szenen verwalten',
|
||||
'scenes.manage.desc': 'Erstelle und bearbeite Szenen für schnellen Zugriff',
|
||||
'scenes.config.empty': 'Keine Szenen erstellt',
|
||||
'scenes.config.empty.hint': 'Klicke auf "Neue Szene" um eine Szene zu erstellen',
|
||||
'scenes.activated': '"{name}" aktiviert',
|
||||
'scenes.created': 'Szene erstellt',
|
||||
'scenes.updated': 'Szene aktualisiert',
|
||||
'scenes.deleted': '"{name}" gelöscht',
|
||||
'scenes.confirm.delete': '"{name}" wirklich löschen?',
|
||||
'scenes.error.name': 'Bitte Namen eingeben',
|
||||
|
||||
// Devices
|
||||
'devices.external': 'Externe Geräte',
|
||||
'devices.control.empty': 'Keine Geräte hinzugefügt',
|
||||
'devices.control.empty.hint': 'Füge Geräte unter Konfiguration hinzu',
|
||||
'devices.new.title': 'Neue Geräte',
|
||||
'devices.new.desc': 'Unprovisionierte Matter-Geräte in der Nähe',
|
||||
'devices.searching': 'Suche nach Geräten...',
|
||||
'devices.unpaired.empty': 'Keine neuen Geräte gefunden',
|
||||
'devices.unpaired.empty.hint': 'Drücke "Geräte suchen" um nach Matter-Geräten zu suchen',
|
||||
'devices.paired.title': 'Zugeordnete Geräte',
|
||||
'devices.paired.desc': 'Bereits hinzugefügte externe Geräte',
|
||||
'devices.paired.empty': 'Keine Geräte hinzugefügt',
|
||||
'devices.none.available': 'Keine Geräte verfügbar',
|
||||
'devices.found': '{count} Gerät(e) gefunden',
|
||||
'devices.added': '"{name}" erfolgreich hinzugefügt',
|
||||
'devices.removed': '"{name}" entfernt',
|
||||
'devices.name.updated': 'Name aktualisiert',
|
||||
'devices.confirm.remove': '"{name}" wirklich entfernen?',
|
||||
|
||||
// WiFi
|
||||
'wifi.config.title': 'WLAN Konfiguration',
|
||||
'wifi.ssid': 'WLAN Name (SSID)',
|
||||
'wifi.ssid.placeholder': 'Netzwerkname eingeben',
|
||||
'wifi.password': 'WLAN Passwort',
|
||||
'wifi.password.short': 'Passwort',
|
||||
'wifi.password.placeholder': 'Passwort eingeben',
|
||||
'wifi.available': 'Verfügbare Netzwerke',
|
||||
'wifi.scan.hint': 'Nach Netzwerken suchen...',
|
||||
'wifi.status.title': 'Verbindungsstatus',
|
||||
'wifi.status.status': 'Status:',
|
||||
'wifi.status.ip': 'IP-Adresse:',
|
||||
'wifi.status.signal': 'Signal:',
|
||||
'wifi.connected': '✅ Verbunden',
|
||||
'wifi.disconnected': '❌ Nicht verbunden',
|
||||
'wifi.unavailable': '⚠️ Status nicht verfügbar',
|
||||
'wifi.searching': 'Suche läuft...',
|
||||
'wifi.scan.error': 'Fehler beim Scannen',
|
||||
'wifi.scan.failed': 'Netzwerksuche fehlgeschlagen',
|
||||
'wifi.saved': 'WLAN-Konfiguration gespeichert! Gerät verbindet sich...',
|
||||
'wifi.error.ssid': 'Bitte WLAN-Name eingeben',
|
||||
'wifi.error.save': 'Fehler beim Speichern',
|
||||
'wifi.networks.found': '{count} Netzwerk(e) gefunden',
|
||||
|
||||
// Schema Editor
|
||||
'schema.editor.title': 'Licht-Schema Editor',
|
||||
'schema.file': 'Schema-Datei',
|
||||
'schema.loading': 'Schema wird geladen...',
|
||||
'schema.header.time': 'Zeit',
|
||||
'schema.header.color': 'Farbe',
|
||||
'schema.loaded': '{file} erfolgreich geladen',
|
||||
'schema.saved': '{file} erfolgreich gespeichert!',
|
||||
'schema.demo': 'Demo-Daten geladen (Server nicht erreichbar)',
|
||||
|
||||
// Color Modal
|
||||
'modal.color.title': 'Farbe wählen',
|
||||
|
||||
// Scene Modal
|
||||
'modal.scene.new': 'Neue Szene erstellen',
|
||||
'modal.scene.edit': 'Szene bearbeiten',
|
||||
'scene.name': 'Name',
|
||||
'scene.name.placeholder': 'z.B. Abendstimmung',
|
||||
'scene.icon': 'Icon auswählen',
|
||||
'scene.actions': 'Aktionen',
|
||||
'scene.action.light': 'Licht Ein/Aus',
|
||||
'scene.action.mode': 'Modus setzen',
|
||||
'scene.action.schema': 'Schema wählen',
|
||||
'scene.light.on': 'Einschalten',
|
||||
'scene.light.off': 'Ausschalten',
|
||||
|
||||
// Buttons
|
||||
'btn.scan': '🔍 Suchen',
|
||||
'btn.save': '💾 Speichern',
|
||||
'btn.load': '🔄 Laden',
|
||||
'btn.cancel': 'Abbrechen',
|
||||
'btn.apply': 'Übernehmen',
|
||||
'btn.new.scene': '➕ Neue Szene',
|
||||
'btn.scan.devices': '🔍 Geräte suchen',
|
||||
'btn.add': 'Hinzufügen',
|
||||
'btn.remove': 'Entfernen',
|
||||
'btn.edit': 'Bearbeiten',
|
||||
'btn.delete': 'Löschen',
|
||||
|
||||
// Captive Portal
|
||||
'captive.title': 'System Control - WLAN Setup',
|
||||
'captive.subtitle': 'WLAN-Einrichtung',
|
||||
'captive.scan': '📡 Netzwerke suchen',
|
||||
'captive.scanning': 'Suche nach Netzwerken...',
|
||||
'captive.or.manual': 'oder manuell eingeben',
|
||||
'captive.password.placeholder': 'WLAN-Passwort',
|
||||
'captive.connect': '💾 Verbinden',
|
||||
'captive.note.title': 'Hinweis:',
|
||||
'captive.note.text': 'Nach dem Speichern verbindet sich das Gerät mit dem gewählten Netzwerk. Diese Seite wird dann nicht mehr erreichbar sein. Verbinden Sie sich mit Ihrem normalen WLAN, um auf das Gerät zuzugreifen.',
|
||||
'captive.connecting': 'Verbindung wird hergestellt... {seconds}s',
|
||||
'captive.done': 'Gerät sollte jetzt verbunden sein. Sie können diese Seite schließen.',
|
||||
|
||||
// General
|
||||
'loading': 'Laden...',
|
||||
'error': 'Fehler',
|
||||
'success': 'Erfolg'
|
||||
},
|
||||
|
||||
en: {
|
||||
// Page
|
||||
'page.title': 'System Control',
|
||||
|
||||
// Main Tabs
|
||||
'tab.control': '🎛️ Control',
|
||||
'tab.config': '⚙️ Settings',
|
||||
|
||||
// Sub Tabs
|
||||
'subtab.wifi': '📶 WiFi',
|
||||
'subtab.schema': '💡 Schema',
|
||||
'subtab.devices': '🔗 Devices',
|
||||
'subtab.scenes': '🎬 Scenes',
|
||||
|
||||
// Light Control
|
||||
'control.light.title': 'Light Control',
|
||||
'control.light.onoff': 'On/Off',
|
||||
'control.light.light': 'Light',
|
||||
'control.mode.title': 'Operating Mode',
|
||||
'control.schema.active': 'Active Schema',
|
||||
'control.status.title': 'Current Status',
|
||||
'control.status.mode': 'Mode',
|
||||
'control.status.schema': 'Schema',
|
||||
'control.status.color': 'Current Color',
|
||||
|
||||
// Common
|
||||
'common.on': 'ON',
|
||||
'common.off': 'OFF',
|
||||
'common.loading': 'Loading...',
|
||||
|
||||
// Modes
|
||||
'mode.day': 'Day',
|
||||
'mode.night': 'Night',
|
||||
'mode.simulation': 'Simulation',
|
||||
|
||||
// Schema names
|
||||
'schema.name.1': 'Schema 1 (Standard)',
|
||||
'schema.name.2': 'Schema 2 (Warm)',
|
||||
'schema.name.3': 'Schema 3 (Natural)',
|
||||
|
||||
// Scenes
|
||||
'scenes.title': 'Scenes',
|
||||
'scenes.empty': 'No scenes defined',
|
||||
'scenes.empty.hint': 'Create scenes in settings',
|
||||
'scenes.manage.title': 'Manage Scenes',
|
||||
'scenes.manage.desc': 'Create and edit scenes for quick access',
|
||||
'scenes.config.empty': 'No scenes created',
|
||||
'scenes.config.empty.hint': 'Click "New Scene" to create a scene',
|
||||
'scenes.activated': '"{name}" activated',
|
||||
'scenes.created': 'Scene created',
|
||||
'scenes.updated': 'Scene updated',
|
||||
'scenes.deleted': '"{name}" deleted',
|
||||
'scenes.confirm.delete': 'Really delete "{name}"?',
|
||||
'scenes.error.name': 'Please enter a name',
|
||||
|
||||
// Devices
|
||||
'devices.external': 'External Devices',
|
||||
'devices.control.empty': 'No devices added',
|
||||
'devices.control.empty.hint': 'Add devices in settings',
|
||||
'devices.new.title': 'New Devices',
|
||||
'devices.new.desc': 'Unprovisioned Matter devices nearby',
|
||||
'devices.searching': 'Searching for devices...',
|
||||
'devices.unpaired.empty': 'No new devices found',
|
||||
'devices.unpaired.empty.hint': 'Press "Scan devices" to search for Matter devices',
|
||||
'devices.paired.title': 'Paired Devices',
|
||||
'devices.paired.desc': 'Already added external devices',
|
||||
'devices.paired.empty': 'No devices added',
|
||||
'devices.none.available': 'No devices available',
|
||||
'devices.found': '{count} device(s) found',
|
||||
'devices.added': '"{name}" added successfully',
|
||||
'devices.removed': '"{name}" removed',
|
||||
'devices.name.updated': 'Name updated',
|
||||
'devices.confirm.remove': 'Really remove "{name}"?',
|
||||
|
||||
// WiFi
|
||||
'wifi.config.title': 'WiFi Configuration',
|
||||
'wifi.ssid': 'WiFi Name (SSID)',
|
||||
'wifi.ssid.placeholder': 'Enter network name',
|
||||
'wifi.password': 'WiFi Password',
|
||||
'wifi.password.short': 'Password',
|
||||
'wifi.password.placeholder': 'Enter password',
|
||||
'wifi.available': 'Available Networks',
|
||||
'wifi.scan.hint': 'Search for networks...',
|
||||
'wifi.status.title': 'Connection Status',
|
||||
'wifi.status.status': 'Status:',
|
||||
'wifi.status.ip': 'IP Address:',
|
||||
'wifi.status.signal': 'Signal:',
|
||||
'wifi.connected': '✅ Connected',
|
||||
'wifi.disconnected': '❌ Not connected',
|
||||
'wifi.unavailable': '⚠️ Status unavailable',
|
||||
'wifi.searching': 'Searching...',
|
||||
'wifi.scan.error': 'Scan error',
|
||||
'wifi.scan.failed': 'Network scan failed',
|
||||
'wifi.saved': 'WiFi configuration saved! Device connecting...',
|
||||
'wifi.error.ssid': 'Please enter WiFi name',
|
||||
'wifi.error.save': 'Error saving',
|
||||
'wifi.networks.found': '{count} network(s) found',
|
||||
|
||||
// Schema Editor
|
||||
'schema.editor.title': 'Light Schema Editor',
|
||||
'schema.file': 'Schema File',
|
||||
'schema.loading': 'Loading schema...',
|
||||
'schema.header.time': 'Time',
|
||||
'schema.header.color': 'Color',
|
||||
'schema.loaded': '{file} loaded successfully',
|
||||
'schema.saved': '{file} saved successfully!',
|
||||
'schema.demo': 'Demo data loaded (server unreachable)',
|
||||
|
||||
// Color Modal
|
||||
'modal.color.title': 'Choose Color',
|
||||
|
||||
// Scene Modal
|
||||
'modal.scene.new': 'Create New Scene',
|
||||
'modal.scene.edit': 'Edit Scene',
|
||||
'scene.name': 'Name',
|
||||
'scene.name.placeholder': 'e.g. Evening Mood',
|
||||
'scene.icon': 'Choose Icon',
|
||||
'scene.actions': 'Actions',
|
||||
'scene.action.light': 'Light On/Off',
|
||||
'scene.action.mode': 'Set Mode',
|
||||
'scene.action.schema': 'Choose Schema',
|
||||
'scene.light.on': 'Turn On',
|
||||
'scene.light.off': 'Turn Off',
|
||||
|
||||
// Buttons
|
||||
'btn.scan': '🔍 Scan',
|
||||
'btn.save': '💾 Save',
|
||||
'btn.load': '🔄 Load',
|
||||
'btn.cancel': 'Cancel',
|
||||
'btn.apply': 'Apply',
|
||||
'btn.new.scene': '➕ New Scene',
|
||||
'btn.scan.devices': '🔍 Scan Devices',
|
||||
'btn.add': 'Add',
|
||||
'btn.remove': 'Remove',
|
||||
'btn.edit': 'Edit',
|
||||
'btn.delete': 'Delete',
|
||||
|
||||
// Captive Portal
|
||||
'captive.title': 'System Control - WiFi Setup',
|
||||
'captive.subtitle': 'WiFi Setup',
|
||||
'captive.scan': '📡 Scan Networks',
|
||||
'captive.scanning': 'Scanning for networks...',
|
||||
'captive.or.manual': 'or enter manually',
|
||||
'captive.password.placeholder': 'WiFi password',
|
||||
'captive.connect': '💾 Connect',
|
||||
'captive.note.title': 'Note:',
|
||||
'captive.note.text': 'After saving, the device will connect to the selected network. This page will no longer be accessible. Connect to your regular WiFi to access the device.',
|
||||
'captive.connecting': 'Connecting... {seconds}s',
|
||||
'captive.done': 'Device should now be connected. You can close this page.',
|
||||
|
||||
// General
|
||||
'loading': 'Loading...',
|
||||
'error': 'Error',
|
||||
'success': 'Success'
|
||||
}
|
||||
};
|
||||
|
||||
// Current language
|
||||
let currentLang = localStorage.getItem('lang') || 'de';
|
||||
|
||||
/**
|
||||
* Get translation for a key
|
||||
* @param {string} key - Translation key
|
||||
* @param {object} params - Optional parameters for interpolation
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
function t(key, params = {}) {
|
||||
const lang = translations[currentLang] || translations.de;
|
||||
let text = lang[key] || translations.de[key] || key;
|
||||
|
||||
// Replace parameters like {count}, {name}, etc.
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current language
|
||||
* @param {string} lang - Language code ('de' or 'en')
|
||||
*/
|
||||
function setLanguage(lang) {
|
||||
if (translations[lang]) {
|
||||
currentLang = lang;
|
||||
localStorage.setItem('lang', lang);
|
||||
document.documentElement.lang = lang;
|
||||
updatePageLanguage();
|
||||
updateLanguageToggle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between languages
|
||||
*/
|
||||
function toggleLanguage() {
|
||||
setLanguage(currentLang === 'de' ? 'en' : 'de');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
* @returns {string} Current language code
|
||||
*/
|
||||
function getCurrentLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all elements with data-i18n attribute
|
||||
*/
|
||||
function updatePageLanguage() {
|
||||
// Update elements with data-i18n attribute
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.textContent = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-placeholder attribute
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.placeholder = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-title attribute
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-title');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.title = translated;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-aria attribute
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-aria');
|
||||
const translated = t(key);
|
||||
if (translated !== key) {
|
||||
el.setAttribute('aria-label', translated);
|
||||
}
|
||||
});
|
||||
|
||||
// Update page title
|
||||
const titleEl = document.querySelector('title[data-i18n]');
|
||||
if (titleEl) {
|
||||
document.title = t(titleEl.getAttribute('data-i18n'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update language toggle button
|
||||
*/
|
||||
function updateLanguageToggle() {
|
||||
const langFlag = document.getElementById('lang-flag');
|
||||
const langLabel = document.getElementById('lang-label');
|
||||
|
||||
if (langFlag) {
|
||||
langFlag.textContent = currentLang === 'de' ? '🇩🇪' : '🇬🇧';
|
||||
}
|
||||
if (langLabel) {
|
||||
langLabel.textContent = currentLang.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n
|
||||
*/
|
||||
function initI18n() {
|
||||
// Check browser language as fallback
|
||||
if (!localStorage.getItem('lang')) {
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (translations[browserLang]) {
|
||||
currentLang = browserLang;
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.lang = currentLang;
|
||||
updatePageLanguage();
|
||||
updateLanguageToggle();
|
||||
}
|
||||
102
firmware/storage/www/js/light.js
Normal file
102
firmware/storage/www/js/light.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// Light control
|
||||
async function toggleLight() {
|
||||
lightOn = !lightOn;
|
||||
updateLightToggle();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/light/power', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ on: lightOn })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('light-status', `${t('control.light.light')} ${lightOn ? t('common.on') : t('common.off')}`, 'success');
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('light-status', `Demo: ${t('control.light.light')} ${lightOn ? t('common.on') : t('common.off')}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function updateLightToggle() {
|
||||
const toggle = document.getElementById('light-toggle');
|
||||
const state = document.getElementById('light-state');
|
||||
const icon = document.getElementById('light-icon');
|
||||
|
||||
if (lightOn) {
|
||||
toggle.classList.add('active');
|
||||
state.textContent = t('common.on');
|
||||
icon.textContent = '💡';
|
||||
} else {
|
||||
toggle.classList.remove('active');
|
||||
state.textContent = t('common.off');
|
||||
icon.textContent = '💡';
|
||||
}
|
||||
}
|
||||
|
||||
// Mode control
|
||||
async function setMode(mode) {
|
||||
currentMode = mode;
|
||||
updateModeButtons();
|
||||
updateSimulationOptions();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/light/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const modeName = t(`mode.${mode}`);
|
||||
showStatus('mode-status', `${t('control.status.mode')}: "${modeName}"`, 'success');
|
||||
document.getElementById('current-mode').textContent = modeName;
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
const modeName = t(`mode.${mode}`);
|
||||
showStatus('mode-status', `Demo: ${t('control.status.mode')} "${modeName}"`, 'success');
|
||||
document.getElementById('current-mode').textContent = modeName;
|
||||
}
|
||||
}
|
||||
|
||||
function updateModeButtons() {
|
||||
document.querySelectorAll('.mode-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById(`mode-${currentMode}`).classList.add('active');
|
||||
}
|
||||
|
||||
function updateSimulationOptions() {
|
||||
const options = document.getElementById('simulation-options');
|
||||
if (currentMode === 'simulation') {
|
||||
options.classList.add('visible');
|
||||
} else {
|
||||
options.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveSchema() {
|
||||
const schema = document.getElementById('active-schema').value;
|
||||
const schemaNum = schema.replace('schema_0', '').replace('.csv', '');
|
||||
const schemaName = t(`schema.name.${schemaNum}`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/light/schema', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ schema })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('mode-status', `${t('control.status.schema')}: "${schemaName}"`, 'success');
|
||||
document.getElementById('current-schema').textContent = schemaName;
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('mode-status', `Demo: ${schemaName}`, 'success');
|
||||
document.getElementById('current-schema').textContent = schemaName;
|
||||
}
|
||||
}
|
||||
330
firmware/storage/www/js/scenes.js
Normal file
330
firmware/storage/www/js/scenes.js
Normal file
@@ -0,0 +1,330 @@
|
||||
// Scene functions
|
||||
async function loadScenes() {
|
||||
try {
|
||||
const response = await fetch('/api/scenes');
|
||||
scenes = await response.json();
|
||||
} catch (error) {
|
||||
// Demo data
|
||||
scenes = [
|
||||
{ id: 'scene-1', name: 'Abendstimmung', icon: '🌅', actions: { light: 'on', mode: 'simulation', schema: 'schema_02.csv' } },
|
||||
{ id: 'scene-2', name: 'Nachtmodus', icon: '🌙', actions: { light: 'on', mode: 'night' } }
|
||||
];
|
||||
}
|
||||
renderScenesConfig();
|
||||
renderScenesControl();
|
||||
}
|
||||
|
||||
function renderScenesConfig() {
|
||||
const list = document.getElementById('scenes-config-list');
|
||||
const noScenes = document.getElementById('no-scenes-config');
|
||||
|
||||
list.querySelectorAll('.scene-config-item').forEach(el => el.remove());
|
||||
|
||||
if (scenes.length === 0) {
|
||||
noScenes.style.display = 'flex';
|
||||
} else {
|
||||
noScenes.style.display = 'none';
|
||||
scenes.forEach(scene => {
|
||||
const item = createSceneConfigItem(scene);
|
||||
list.insertBefore(item, noScenes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createSceneConfigItem(scene) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-config-item';
|
||||
item.dataset.id = scene.id;
|
||||
|
||||
const actionsText = [];
|
||||
if (scene.actions.light) actionsText.push(`${t('control.light.light')} ${scene.actions.light === 'on' ? t('common.on') : t('common.off')}`);
|
||||
if (scene.actions.mode) actionsText.push(`${t('control.status.mode')}: ${t('mode.' + scene.actions.mode)}`);
|
||||
if (scene.actions.schema) actionsText.push(`${t('control.status.schema')}: ${scene.actions.schema.replace('.csv', '')}`);
|
||||
if (scene.actions.devices && scene.actions.devices.length > 0) {
|
||||
actionsText.push(t('devices.found', { count: scene.actions.devices.length }));
|
||||
}
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="scene-info">
|
||||
<span class="scene-icon">${scene.icon}</span>
|
||||
<div class="scene-details">
|
||||
<span class="scene-name">${scene.name}</span>
|
||||
<span class="scene-actions-text">${actionsText.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scene-buttons">
|
||||
<button class="btn btn-secondary btn-small" onclick="editScene('${scene.id}')">✏️</button>
|
||||
<button class="btn btn-secondary btn-small btn-danger" onclick="deleteScene('${scene.id}', '${scene.name}')">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderScenesControl() {
|
||||
const list = document.getElementById('scenes-control-list');
|
||||
const noScenes = document.getElementById('no-scenes-control');
|
||||
|
||||
list.querySelectorAll('.scene-btn').forEach(el => el.remove());
|
||||
|
||||
if (scenes.length === 0) {
|
||||
noScenes.style.display = 'flex';
|
||||
} else {
|
||||
noScenes.style.display = 'none';
|
||||
scenes.forEach(scene => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'scene-btn';
|
||||
btn.onclick = () => activateScene(scene.id);
|
||||
btn.innerHTML = `
|
||||
<span class="scene-btn-icon">${scene.icon}</span>
|
||||
<span class="scene-btn-name">${scene.name}</span>
|
||||
`;
|
||||
list.insertBefore(btn, noScenes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openSceneModal() {
|
||||
currentEditScene = null;
|
||||
selectedSceneIcon = '🌅';
|
||||
document.getElementById('scene-modal-title').textContent = t('modal.scene.new');
|
||||
document.getElementById('scene-name').value = '';
|
||||
document.getElementById('scene-action-light').checked = true;
|
||||
document.getElementById('scene-light-state').value = 'on';
|
||||
document.getElementById('scene-action-mode').checked = false;
|
||||
document.getElementById('scene-mode-value').value = 'simulation';
|
||||
document.getElementById('scene-action-schema').checked = false;
|
||||
document.getElementById('scene-schema-value').value = 'schema_01.csv';
|
||||
|
||||
document.querySelectorAll('.icon-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.icon === '🌅');
|
||||
});
|
||||
|
||||
renderSceneDevicesList();
|
||||
|
||||
document.getElementById('scene-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function editScene(sceneId) {
|
||||
const scene = scenes.find(s => s.id === sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
currentEditScene = sceneId;
|
||||
selectedSceneIcon = scene.icon;
|
||||
document.getElementById('scene-modal-title').textContent = t('modal.scene.edit');
|
||||
document.getElementById('scene-name').value = scene.name;
|
||||
|
||||
document.getElementById('scene-action-light').checked = !!scene.actions.light;
|
||||
document.getElementById('scene-light-state').value = scene.actions.light || 'on';
|
||||
document.getElementById('scene-action-mode').checked = !!scene.actions.mode;
|
||||
document.getElementById('scene-mode-value').value = scene.actions.mode || 'simulation';
|
||||
document.getElementById('scene-action-schema').checked = !!scene.actions.schema;
|
||||
document.getElementById('scene-schema-value').value = scene.actions.schema || 'schema_01.csv';
|
||||
|
||||
document.querySelectorAll('.icon-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.icon === scene.icon);
|
||||
});
|
||||
|
||||
renderSceneDevicesList(scene.actions.devices || []);
|
||||
|
||||
document.getElementById('scene-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Render device list in scene modal
|
||||
function renderSceneDevicesList(selectedDevices = []) {
|
||||
const list = document.getElementById('scene-devices-list');
|
||||
const noDevices = document.getElementById('no-scene-devices');
|
||||
|
||||
// Remove previous entries (except empty-state)
|
||||
list.querySelectorAll('.scene-device-item').forEach(el => el.remove());
|
||||
|
||||
if (pairedDevices.length === 0) {
|
||||
noDevices.style.display = 'flex';
|
||||
} else {
|
||||
noDevices.style.display = 'none';
|
||||
pairedDevices.forEach(device => {
|
||||
const selectedDevice = selectedDevices.find(d => d.id === device.id);
|
||||
const isSelected = !!selectedDevice;
|
||||
const deviceState = selectedDevice ? selectedDevice.state : 'on';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'scene-device-item';
|
||||
item.dataset.id = device.id;
|
||||
|
||||
const icon = device.type === 'light' ? '💡' : device.type === 'sensor' ? '🌡️' : '📟';
|
||||
|
||||
item.innerHTML = `
|
||||
<label class="scene-device-checkbox">
|
||||
<input type="checkbox" ${isSelected ? 'checked' : ''}
|
||||
onchange="toggleSceneDevice('${device.id}')">
|
||||
<span class="device-icon">${icon}</span>
|
||||
<span class="device-name">${device.name}</span>
|
||||
</label>
|
||||
${device.type === 'light' ? `
|
||||
<select class="scene-device-state" id="scene-device-state-${device.id}"
|
||||
${!isSelected ? 'disabled' : ''}>
|
||||
<option value="on" ${deviceState === 'on' ? 'selected' : ''}>${t('scene.light.on')}</option>
|
||||
<option value="off" ${deviceState === 'off' ? 'selected' : ''}>${t('scene.light.off')}</option>
|
||||
</select>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
list.insertBefore(item, noDevices);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSceneDevice(deviceId) {
|
||||
const stateSelect = document.getElementById(`scene-device-state-${deviceId}`);
|
||||
if (stateSelect) {
|
||||
const checkbox = document.querySelector(`.scene-device-item[data-id="${deviceId}"] input[type="checkbox"]`);
|
||||
stateSelect.disabled = !checkbox.checked;
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedSceneDevices() {
|
||||
const devices = [];
|
||||
document.querySelectorAll('.scene-device-item').forEach(item => {
|
||||
const checkbox = item.querySelector('input[type="checkbox"]');
|
||||
if (checkbox && checkbox.checked) {
|
||||
const deviceId = item.dataset.id;
|
||||
const stateSelect = document.getElementById(`scene-device-state-${deviceId}`);
|
||||
devices.push({
|
||||
id: deviceId,
|
||||
state: stateSelect ? stateSelect.value : 'on'
|
||||
});
|
||||
}
|
||||
});
|
||||
return devices;
|
||||
}
|
||||
|
||||
function closeSceneModal() {
|
||||
document.getElementById('scene-modal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
currentEditScene = null;
|
||||
}
|
||||
|
||||
function selectSceneIcon(icon) {
|
||||
selectedSceneIcon = icon;
|
||||
document.querySelectorAll('.icon-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.icon === icon);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveScene() {
|
||||
const name = document.getElementById('scene-name').value.trim();
|
||||
if (!name) {
|
||||
showStatus('scenes-status', t('scenes.error.name'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = {};
|
||||
if (document.getElementById('scene-action-light').checked) {
|
||||
actions.light = document.getElementById('scene-light-state').value;
|
||||
}
|
||||
if (document.getElementById('scene-action-mode').checked) {
|
||||
actions.mode = document.getElementById('scene-mode-value').value;
|
||||
}
|
||||
if (document.getElementById('scene-action-schema').checked) {
|
||||
actions.schema = document.getElementById('scene-schema-value').value;
|
||||
}
|
||||
|
||||
// Add device actions
|
||||
const selectedDevices = getSelectedSceneDevices();
|
||||
if (selectedDevices.length > 0) {
|
||||
actions.devices = selectedDevices;
|
||||
}
|
||||
|
||||
const sceneData = {
|
||||
id: currentEditScene || `scene-${Date.now()}`,
|
||||
name,
|
||||
icon: selectedSceneIcon,
|
||||
actions
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scenes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sceneData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('scenes-status', currentEditScene ? t('scenes.updated') : t('scenes.created'), 'success');
|
||||
loadScenes();
|
||||
closeSceneModal();
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
if (currentEditScene) {
|
||||
const index = scenes.findIndex(s => s.id === currentEditScene);
|
||||
if (index !== -1) scenes[index] = sceneData;
|
||||
} else {
|
||||
scenes.push(sceneData);
|
||||
}
|
||||
renderScenesConfig();
|
||||
renderScenesControl();
|
||||
showStatus('scenes-status', `Demo: ${currentEditScene ? t('scenes.updated') : t('scenes.created')}`, 'success');
|
||||
closeSceneModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScene(sceneId, name) {
|
||||
if (!confirm(t('scenes.confirm.delete', { name }))) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scenes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: sceneId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('scenes-status', t('scenes.deleted', { name }), 'success');
|
||||
loadScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
// Demo mode
|
||||
scenes = scenes.filter(s => s.id !== sceneId);
|
||||
renderScenesConfig();
|
||||
renderScenesControl();
|
||||
showStatus('scenes-status', `Demo: ${t('scenes.deleted', { name })}`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async function activateScene(sceneId) {
|
||||
const scene = scenes.find(s => s.id === sceneId);
|
||||
if (!scene) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/scenes/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: sceneId })
|
||||
});
|
||||
showStatus('scenes-control-status', t('scenes.activated', { name: scene.name }), 'success');
|
||||
} catch (error) {
|
||||
// Demo: Execute actions
|
||||
if (scene.actions.light === 'on') {
|
||||
lightOn = true;
|
||||
updateLightToggle();
|
||||
} else if (scene.actions.light === 'off') {
|
||||
lightOn = false;
|
||||
updateLightToggle();
|
||||
}
|
||||
if (scene.actions.mode) {
|
||||
currentMode = scene.actions.mode;
|
||||
updateModeButtons();
|
||||
updateSimulationOptions();
|
||||
}
|
||||
// Device actions in demo mode
|
||||
if (scene.actions.devices && scene.actions.devices.length > 0) {
|
||||
scene.actions.devices.forEach(deviceAction => {
|
||||
console.log(`Demo: Device ${deviceAction.id} -> ${deviceAction.state}`);
|
||||
});
|
||||
}
|
||||
showStatus('scenes-control-status', `Demo: ${t('scenes.activated', { name: scene.name })}`, 'success');
|
||||
}
|
||||
}
|
||||
208
firmware/storage/www/js/schema.js
Normal file
208
firmware/storage/www/js/schema.js
Normal file
@@ -0,0 +1,208 @@
|
||||
// Schema functions
|
||||
async function loadSchema() {
|
||||
const schemaFile = document.getElementById('schema-select').value;
|
||||
const grid = document.getElementById('schema-grid');
|
||||
const loading = document.getElementById('schema-loading');
|
||||
|
||||
grid.innerHTML = '';
|
||||
loading.classList.add('active');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schema/${schemaFile}`);
|
||||
const text = await response.text();
|
||||
|
||||
schemaData = parseCSV(text);
|
||||
renderSchemaGrid();
|
||||
showStatus('schema-status', t('schema.loaded', { file: schemaFile }), 'success');
|
||||
} catch (error) {
|
||||
// Demo data for local testing
|
||||
schemaData = generateDemoData();
|
||||
renderSchemaGrid();
|
||||
showStatus('schema-status', t('schema.demo'), 'error');
|
||||
} finally {
|
||||
loading.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
const lines = text.trim().split('\n');
|
||||
return lines
|
||||
.filter(line => line.trim() && !line.startsWith('#'))
|
||||
.map(line => {
|
||||
const values = line.split(',').map(v => parseInt(v.trim()));
|
||||
return {
|
||||
r: values[0] || 0,
|
||||
g: values[1] || 0,
|
||||
b: values[2] || 0,
|
||||
v1: values[3] || 0,
|
||||
v2: values[4] || 0,
|
||||
v3: values[5] || 250
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateDemoData() {
|
||||
const data = [];
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const hour = i / 2;
|
||||
let r, g, b;
|
||||
|
||||
if (hour < 6 || hour >= 22) {
|
||||
r = 25; g = 25; b = 112;
|
||||
} else if (hour < 8) {
|
||||
const t = (hour - 6) / 2;
|
||||
r = Math.round(25 + 230 * t);
|
||||
g = Math.round(25 + 150 * t);
|
||||
b = Math.round(112 + 50 * t);
|
||||
} else if (hour < 18) {
|
||||
r = 255; g = 240; b = 220;
|
||||
} else {
|
||||
const t = (hour - 18) / 4;
|
||||
r = Math.round(255 - 230 * t);
|
||||
g = Math.round(240 - 215 * t);
|
||||
b = Math.round(220 - 108 * t);
|
||||
}
|
||||
|
||||
data.push({
|
||||
r, g, b,
|
||||
v1: 0,
|
||||
v2: Math.round(100 + 155 * Math.sin(Math.PI * hour / 12)),
|
||||
v3: 250
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderSchemaGrid() {
|
||||
const grid = document.getElementById('schema-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < 48; i++) {
|
||||
const hour = Math.floor(i / 2);
|
||||
const minute = (i % 2) * 30;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
const data = schemaData[i] || { r: 0, g: 0, b: 0, v1: 0, v2: 100, v3: 250 };
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'time-row';
|
||||
row.dataset.index = i;
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="time-label">${time}</span>
|
||||
<div class="color-preview"
|
||||
style="background: rgb(${data.r}, ${data.g}, ${data.b})"
|
||||
onclick="openColorModal(${i})"
|
||||
title="Tippen zum Bearbeiten"></div>
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.r}"
|
||||
onchange="updateValue(${i}, 'r', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.g}"
|
||||
onchange="updateValue(${i}, 'g', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.b}"
|
||||
onchange="updateValue(${i}, 'b', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v1}"
|
||||
onchange="updateValue(${i}, 'v1', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v2}"
|
||||
onchange="updateValue(${i}, 'v2', this.value)">
|
||||
<input type="number" class="value-input" inputmode="numeric" pattern="[0-9]*" min="0" max="255" value="${data.v3}"
|
||||
onchange="updateValue(${i}, 'v3', this.value)">
|
||||
`;
|
||||
|
||||
grid.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue(index, field, value) {
|
||||
const numValue = Math.max(0, Math.min(255, parseInt(value) || 0));
|
||||
schemaData[index][field] = numValue;
|
||||
|
||||
const row = document.querySelector(`.time-row[data-index="${index}"]`);
|
||||
if (row) {
|
||||
const preview = row.querySelector('.color-preview');
|
||||
const data = schemaData[index];
|
||||
preview.style.background = `rgb(${data.r}, ${data.g}, ${data.b})`;
|
||||
}
|
||||
}
|
||||
|
||||
function openColorModal(index) {
|
||||
currentEditRow = index;
|
||||
const data = schemaData[index];
|
||||
|
||||
document.getElementById('rangeR').value = data.r;
|
||||
document.getElementById('rangeG').value = data.g;
|
||||
document.getElementById('rangeB').value = data.b;
|
||||
|
||||
const hour = Math.floor(index / 2);
|
||||
const minute = (index % 2) * 30;
|
||||
document.getElementById('modal-time').textContent =
|
||||
`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
|
||||
updateModalColor();
|
||||
document.getElementById('color-modal').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeColorModal() {
|
||||
document.getElementById('color-modal').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
currentEditRow = null;
|
||||
}
|
||||
|
||||
function updateModalColor() {
|
||||
const r = document.getElementById('rangeR').value;
|
||||
const g = document.getElementById('rangeG').value;
|
||||
const b = document.getElementById('rangeB').value;
|
||||
|
||||
document.getElementById('valR').textContent = r;
|
||||
document.getElementById('valG').textContent = g;
|
||||
document.getElementById('valB').textContent = b;
|
||||
|
||||
document.getElementById('preview-large').style.background = `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function applyColor() {
|
||||
if (currentEditRow === null) return;
|
||||
|
||||
const r = parseInt(document.getElementById('rangeR').value);
|
||||
const g = parseInt(document.getElementById('rangeG').value);
|
||||
const b = parseInt(document.getElementById('rangeB').value);
|
||||
|
||||
schemaData[currentEditRow].r = r;
|
||||
schemaData[currentEditRow].g = g;
|
||||
schemaData[currentEditRow].b = b;
|
||||
|
||||
const row = document.querySelector(`.time-row[data-index="${currentEditRow}"]`);
|
||||
if (row) {
|
||||
const inputs = row.querySelectorAll('.value-input');
|
||||
inputs[0].value = r;
|
||||
inputs[1].value = g;
|
||||
inputs[2].value = b;
|
||||
row.querySelector('.color-preview').style.background = `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
closeColorModal();
|
||||
}
|
||||
|
||||
async function saveSchema() {
|
||||
const schemaFile = document.getElementById('schema-select').value;
|
||||
|
||||
const csv = schemaData.map(row =>
|
||||
`${row.r},${row.g},${row.b},${row.v1},${row.v2},${row.v3}`
|
||||
).join('\n');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/schema/${schemaFile}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/csv' },
|
||||
body: csv
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('schema-status', t('schema.saved', { file: schemaFile }), 'success');
|
||||
} else {
|
||||
throw new Error(t('error'));
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('schema-status', t('error') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
53
firmware/storage/www/js/ui.js
Normal file
53
firmware/storage/www/js/ui.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
const icon = document.getElementById('theme-icon');
|
||||
const label = document.getElementById('theme-label');
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||
|
||||
if (theme === 'light') {
|
||||
icon.textContent = '☀️';
|
||||
label.textContent = 'Light';
|
||||
metaTheme.content = '#f0f2f5';
|
||||
} else {
|
||||
icon.textContent = '🌙';
|
||||
label.textContent = 'Dark';
|
||||
metaTheme.content = '#1a1a2e';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
document.querySelector(`.tab[onclick="switchTab('${tabName}')"]`).classList.add('active');
|
||||
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||
}
|
||||
|
||||
// Sub-tab switching
|
||||
function switchSubTab(subTabName) {
|
||||
document.querySelectorAll('.sub-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.sub-tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
document.querySelector(`.sub-tab[onclick="switchSubTab('${subTabName}')"]`).classList.add('active');
|
||||
document.getElementById(`subtab-${subTabName}`).classList.add('active');
|
||||
|
||||
if (subTabName === 'schema' && schemaData.length === 0) {
|
||||
loadSchema();
|
||||
}
|
||||
}
|
||||
|
||||
// Note: showStatus is defined in wifi-shared.js (loaded first)
|
||||
138
firmware/storage/www/js/websocket.js
Normal file
138
firmware/storage/www/js/websocket.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// WebSocket connection
|
||||
function initWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
clearTimeout(wsReconnectTimer);
|
||||
// Request initial status
|
||||
ws.send(JSON.stringify({ type: 'getStatus' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
} catch (e) {
|
||||
console.error('WebSocket message error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected, reconnecting in 3s...');
|
||||
ws = null;
|
||||
wsReconnectTimer = setTimeout(initWebSocket, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
ws.close();
|
||||
};
|
||||
} catch (error) {
|
||||
console.log('WebSocket not available, using demo mode');
|
||||
initDemoMode();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'status':
|
||||
updateStatusFromData(data);
|
||||
break;
|
||||
case 'color':
|
||||
updateColorPreview(data.r, data.g, data.b);
|
||||
break;
|
||||
case 'wifi':
|
||||
updateWifiStatus(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusFromData(status) {
|
||||
if (status.on !== undefined) {
|
||||
lightOn = status.on;
|
||||
updateLightToggle();
|
||||
}
|
||||
|
||||
if (status.mode) {
|
||||
currentMode = status.mode;
|
||||
updateModeButtons();
|
||||
updateSimulationOptions();
|
||||
}
|
||||
|
||||
if (status.schema) {
|
||||
document.getElementById('active-schema').value = status.schema;
|
||||
const schemaNames = {
|
||||
'schema_01.csv': 'Schema 1',
|
||||
'schema_02.csv': 'Schema 2',
|
||||
'schema_03.csv': 'Schema 3'
|
||||
};
|
||||
document.getElementById('current-schema').textContent = schemaNames[status.schema] || status.schema;
|
||||
}
|
||||
|
||||
if (status.color) {
|
||||
updateColorPreview(status.color.r, status.color.g, status.color.b);
|
||||
}
|
||||
}
|
||||
|
||||
function updateColorPreview(r, g, b) {
|
||||
const colorPreview = document.getElementById('current-color');
|
||||
colorPreview.style.background = `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function updateWifiStatus(status) {
|
||||
document.getElementById('conn-status').textContent = status.connected ? '✅ Verbunden' : '❌ Nicht verbunden';
|
||||
document.getElementById('conn-ip').textContent = status.ip || '-';
|
||||
document.getElementById('conn-rssi').textContent = status.rssi ? `${status.rssi} dBm` : '-';
|
||||
}
|
||||
|
||||
// Send via WebSocket
|
||||
function wsSend(data) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Demo mode for local testing
|
||||
function initDemoMode() {
|
||||
updateSimulationOptions();
|
||||
updateColorPreview(255, 240, 220);
|
||||
|
||||
// Simulate color changes in demo mode
|
||||
let hue = 0;
|
||||
setInterval(() => {
|
||||
if (!ws) {
|
||||
hue = (hue + 1) % 360;
|
||||
const rgb = hslToRgb(hue / 360, 0.7, 0.6);
|
||||
updateColorPreview(rgb.r, rgb.g, rgb.b);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
|
||||
}
|
||||
249
firmware/storage/www/js/wifi-shared.js
Normal file
249
firmware/storage/www/js/wifi-shared.js
Normal file
@@ -0,0 +1,249 @@
|
||||
// Shared WiFi configuration functions
|
||||
// Used by both captive.html and index.html
|
||||
|
||||
/**
|
||||
* Show status message
|
||||
* @param {string} elementId - ID of the status element
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - Type: 'success', 'error', or 'info'
|
||||
*/
|
||||
function showStatus(elementId, message, type) {
|
||||
const status = document.getElementById(elementId);
|
||||
if (!status) return;
|
||||
|
||||
status.textContent = message;
|
||||
status.className = `status ${type}`;
|
||||
|
||||
if (type !== 'info') {
|
||||
setTimeout(() => {
|
||||
status.className = 'status';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for available WiFi networks
|
||||
*/
|
||||
async function scanNetworks() {
|
||||
const loading = document.getElementById('loading');
|
||||
const networkList = document.getElementById('network-list');
|
||||
const select = document.getElementById('available-networks');
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
loading.classList.add('active');
|
||||
}
|
||||
if (networkList) {
|
||||
networkList.style.display = 'none';
|
||||
networkList.innerHTML = '';
|
||||
}
|
||||
if (select) {
|
||||
select.innerHTML = `<option value="">${t('wifi.searching')}</option>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wifi/scan');
|
||||
const networks = await response.json();
|
||||
|
||||
if (loading) {
|
||||
loading.classList.remove('active');
|
||||
}
|
||||
|
||||
// Sort by signal strength
|
||||
networks.sort((a, b) => b.rssi - a.rssi);
|
||||
|
||||
// Render for captive portal (network list)
|
||||
if (networkList) {
|
||||
if (networks.length === 0) {
|
||||
networkList.innerHTML = `<div class="network-item"><span class="network-name">${t('devices.unpaired.empty')}</span></div>`;
|
||||
} else {
|
||||
networks.forEach(network => {
|
||||
const signalIcon = getSignalIcon(network.rssi);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'network-item';
|
||||
item.onclick = () => selectNetwork(network.ssid, item);
|
||||
item.innerHTML = `
|
||||
<span class="network-name">
|
||||
<span class="signal-icon">${signalIcon}</span>
|
||||
${escapeHtml(network.ssid)}
|
||||
</span>
|
||||
<span class="network-signal">${network.rssi} dBm</span>
|
||||
`;
|
||||
networkList.appendChild(item);
|
||||
});
|
||||
}
|
||||
networkList.style.display = 'block';
|
||||
}
|
||||
|
||||
// Render for main interface (select dropdown)
|
||||
if (select) {
|
||||
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
|
||||
networks.forEach(network => {
|
||||
const option = document.createElement('option');
|
||||
option.value = network.ssid;
|
||||
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
// Note: onchange handler is set inline in HTML
|
||||
}
|
||||
|
||||
showStatus('wifi-status', t('wifi.networks.found', { count: networks.length }), 'success');
|
||||
} catch (error) {
|
||||
if (loading) {
|
||||
loading.classList.remove('active');
|
||||
}
|
||||
|
||||
// Demo mode for local testing
|
||||
const demoNetworks = [
|
||||
{ ssid: 'Demo-Netzwerk', rssi: -45 },
|
||||
{ ssid: 'Gast-WLAN', rssi: -67 },
|
||||
{ ssid: 'Nachbar-WiFi', rssi: -82 }
|
||||
];
|
||||
|
||||
if (networkList) {
|
||||
demoNetworks.forEach(network => {
|
||||
const signalIcon = getSignalIcon(network.rssi);
|
||||
const item = document.createElement('div');
|
||||
item.className = 'network-item';
|
||||
item.onclick = () => selectNetwork(network.ssid, item);
|
||||
item.innerHTML = `
|
||||
<span class="network-name">
|
||||
<span class="signal-icon">${signalIcon}</span>
|
||||
${escapeHtml(network.ssid)}
|
||||
</span>
|
||||
<span class="network-signal">${network.rssi} dBm</span>
|
||||
`;
|
||||
networkList.appendChild(item);
|
||||
});
|
||||
networkList.style.display = 'block';
|
||||
}
|
||||
|
||||
if (select) {
|
||||
select.innerHTML = `<option value="">${t('wifi.scan.hint')}</option>`;
|
||||
demoNetworks.forEach(network => {
|
||||
const option = document.createElement('option');
|
||||
option.value = network.ssid;
|
||||
option.textContent = `${network.ssid} (${network.rssi} dBm)`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
showStatus('wifi-status', 'Demo: ' + t('wifi.networks.found', { count: demoNetworks.length }), 'info');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a network from the list (captive portal)
|
||||
* @param {string} ssid - Network SSID
|
||||
* @param {HTMLElement} element - Clicked element
|
||||
*/
|
||||
function selectNetwork(ssid, element) {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.network-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selection to clicked item
|
||||
element.classList.add('selected');
|
||||
|
||||
// Fill in SSID
|
||||
document.getElementById('ssid').value = ssid;
|
||||
|
||||
// Focus password field
|
||||
document.getElementById('password').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get signal strength icon
|
||||
* @param {number} rssi - Signal strength in dBm
|
||||
* @returns {string} Emoji icon
|
||||
*/
|
||||
function getSignalIcon(rssi) {
|
||||
if (rssi >= -50) return '📶';
|
||||
if (rssi >= -60) return '📶';
|
||||
if (rssi >= -70) return '📶';
|
||||
return '📶';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped text
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save WiFi configuration
|
||||
*/
|
||||
async function saveWifi() {
|
||||
const ssid = document.getElementById('ssid').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!ssid) {
|
||||
showStatus('wifi-status', t('wifi.error.ssid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('wifi-status', t('common.loading'), 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wifi/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ssid, password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('wifi-status', t('wifi.saved'), 'success');
|
||||
|
||||
// Show countdown for captive portal
|
||||
if (document.querySelector('.info-box')) {
|
||||
let countdown = 10;
|
||||
const countdownInterval = setInterval(() => {
|
||||
showStatus('wifi-status', t('captive.connecting', { seconds: countdown }), 'success');
|
||||
countdown--;
|
||||
if (countdown < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
showStatus('wifi-status', t('captive.done'), 'success');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || t('wifi.error.save'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('fetch')) {
|
||||
// Demo mode
|
||||
showStatus('wifi-status', 'Demo: ' + t('wifi.saved'), 'success');
|
||||
} else {
|
||||
showStatus('wifi-status', t('error') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status (for main interface)
|
||||
*/
|
||||
async function updateConnectionStatus() {
|
||||
const connStatus = document.getElementById('conn-status');
|
||||
const connIp = document.getElementById('conn-ip');
|
||||
const connRssi = document.getElementById('conn-rssi');
|
||||
|
||||
if (!connStatus) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/wifi/status');
|
||||
const status = await response.json();
|
||||
|
||||
connStatus.textContent = status.connected ? t('wifi.connected') : t('wifi.disconnected');
|
||||
if (connIp) connIp.textContent = status.ip || '-';
|
||||
if (connRssi) connRssi.textContent = status.rssi ? `${status.rssi} dBm` : '-';
|
||||
} catch (error) {
|
||||
connStatus.textContent = t('wifi.unavailable');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user