Compare commits

...

2 Commits

Author SHA1 Message Date
mars3142 271b6a3e9a starting matter over thread
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-04-17 18:06:26 +02:00
mars3142 7678afa5ce latest website iteration (including FW code fixes for it)
Signed-off-by: Peter Siegmund <developer@mars3142.org>
2026-04-07 20:54:16 +02:00
45 changed files with 1261 additions and 112 deletions
+1
View File
@@ -0,0 +1 @@
*.gz filter=lfs diff=lfs merge=lfs -text
+34
View File
@@ -0,0 +1,34 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "ESP-IDF: App Flash (preserve NVS & SPIFFS)",
"type": "shell",
"command": "${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python ${config:idf.currentSetup}/tools/idf.py -p ${config:idf.port} app-flash && ${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python -m esptool --port ${config:idf.port} run",
"options": {
"env": {
"IDF_PATH": "${config:idf.currentSetup}",
"IDF_TOOLS_PATH": "${config:idf.toolsPath}"
}
},
"group": {
"kind": "build",
"isDefault": false
},
"problemMatcher": []
},
{
"label": "ESP-IDF: Storage Flash (SPIFFS only)",
"type": "shell",
"command": "${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python ${config:idf.currentSetup}/tools/idf.py -p ${config:idf.port} storage-flash && ${config:idf.toolsPath}/python_env/idf5.5_py3.14_env/bin/python -m esptool --port ${config:idf.port} run",
"options": {
"env": {
"IDF_PATH": "${config:idf.currentSetup}",
"IDF_TOOLS_PATH": "${config:idf.toolsPath}"
}
},
"group": "build",
"problemMatcher": []
}
]
}
@@ -174,23 +174,32 @@ esp_err_t api_light_status_handler(httpd_req_t *req)
// LED Configuration API
// ============================================================================
static int compare_segments_by_start(const void *a, const void *b)
{
const led_segment_t *seg_a = (const led_segment_t *)a;
const led_segment_t *seg_b = (const led_segment_t *)b;
return (int)seg_a->start - (int)seg_b->start;
}
esp_err_t api_wled_config_get_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET /api/wled/config");
extern led_segment_t segments[LED_SEGMENT_MAX_LEN];
extern size_t segment_count;
size_t required_size = sizeof(segments) * segment_count;
cJSON *json = cJSON_CreateObject();
persistence_manager_t pm;
if (persistence_manager_init(&pm, "led_config") == ESP_OK)
{
segment_count = persistence_manager_get_int(&pm, "segment_count", 0);
size_t required_size = sizeof(led_segment_t) * segment_count;
persistence_manager_get_blob(&pm, "segments", segments, required_size, NULL);
uint8_t segment_count = persistence_manager_get_int(&pm, "segment_count", 0);
persistence_manager_deinit(&pm);
qsort(segments, segment_count, sizeof(led_segment_t), compare_segments_by_start);
cJSON *segments_arr = cJSON_CreateArray();
for (uint8_t i = 0; i < segment_count; ++i)
{
@@ -278,6 +287,8 @@ esp_err_t api_wled_config_post_handler(httpd_req_t *req)
}
cJSON_Delete(json);
qsort(segments, segment_count, sizeof(led_segment_t), compare_segments_by_start);
persistence_manager_t pm;
if (persistence_manager_init(&pm, "led_config") == ESP_OK)
{
@@ -20,12 +20,15 @@ esp_err_t api_static_file_handler(httpd_req_t *req)
const char *uri = req->uri;
wifi_mode_t mode = 0;
esp_wifi_get_mode(&mode);
// Always serve captive.html in AP mode
// In AP mode, redirect root to SPA captive portal route
if (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA)
{
if (strcmp(uri, "/") == 0 || strcmp(uri, "/index.html") == 0)
{
uri = "/captive.html";
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "/#/captive");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
}
}
else
@@ -109,31 +112,8 @@ esp_err_t api_captive_portal_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "Captive portal detection: %s", req->uri);
// Serve captive.html directly (status 200, text/html)
const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH;
char filepath[256];
snprintf(filepath, sizeof(filepath), "%s/captive.html", base_path);
FILE *f = fopen(filepath, "r");
if (!f)
{
ESP_LOGE(TAG, "captive.html not found: %s", filepath);
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_sendstr(req, "Captive portal not available");
return ESP_FAIL;
}
httpd_resp_set_type(req, "text/html");
char buf[512];
size_t read_bytes;
while ((read_bytes = fread(buf, 1, sizeof(buf), f)) > 0)
{
if (httpd_resp_send_chunk(req, buf, read_bytes) != ESP_OK)
{
fclose(f);
ESP_LOGE(TAG, "Failed to send captive chunk");
return ESP_FAIL;
}
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0);
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "/#/captive");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
}
@@ -97,7 +97,11 @@ void wifi_manager_init()
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_err_t err = esp_event_loop_create_default();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE)
{
ESP_ERROR_CHECK(err);
}
// Default WiFi Station
esp_netif_create_default_wifi_sta();
@@ -4,5 +4,6 @@ idf_component_register(SRCS
INCLUDE_DIRS "include"
REQUIRES
mercedes
simulator
u8g2
)
@@ -1,6 +1,9 @@
#include "hermes/screensaver/clock_screensaver.h"
#include "simulator.h"
#include <ctime>
#include <cstring>
ClockScreensaver::ClockScreensaver(u8g2_t *u8g2) : m_u8g2(u8g2)
{
@@ -8,6 +11,14 @@ ClockScreensaver::ClockScreensaver(u8g2_t *u8g2) : m_u8g2(u8g2)
void ClockScreensaver::get_time_string(char *buffer, size_t bufferSize)
{
const char *sim_time = get_time();
if (sim_time != NULL)
{
strncpy(buffer, sim_time, bufferSize - 1);
buffer[bufferSize - 1] = '\0';
return;
}
time_t rawtime;
struct tm *timeinfo;
time(&rawtime);
@@ -399,6 +399,7 @@ void stop_simulation_task(void)
{
TaskHandle_t handle_to_delete = simulation_task_handle;
simulation_task_handle = NULL;
time = NULL;
xSemaphoreGive(simulation_mutex);
// Check if the task still exists before deleting it
+7
View File
@@ -11,6 +11,7 @@
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_system.h>
#include <nvs_flash.h>
#include <sdkconfig.h>
@@ -39,6 +40,12 @@ void app_main(void)
gpio_set_level(WIFI_ANT_CONFIG, 1); // HIGH
#endif
esp_reset_reason_t reset_reason = esp_reset_reason();
if (reset_reason == ESP_RST_PANIC || reset_reason == ESP_RST_TASK_WDT || reset_reason == ESP_RST_INT_WDT)
{
ESP_LOGW("app_main", "Reboot after crash (reason: %d) — continuing normal init", reset_reason);
}
// Initialize NVS
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND)
+4 -7
View File
@@ -24,13 +24,6 @@ CONFIG_LWIP_SNTP_UPDATE_DELAY=14400000
CONFIG_LWIP_SNTP_STARTUP_DELAY=y
CONFIG_LWIP_SNTP_MAXIMUM_STARTUP_DELAY=5000
# ESP PSRAM
CONFIG_SPIRAM=y
# SPI RAM config
CONFIG_SPIRAM_SPEED=80
CONFIG_SPIRAM_USE_CAPS_ALLOC=y
# HTTP Server WebSocket Support
CONFIG_HTTPD_WS_SUPPORT=y
@@ -39,8 +32,12 @@ CONFIG_MQTT_CLIENT_BROKER_URL="mqtts://mqtt.mars3142.dev:8883"
CONFIG_MQTT_CLIENT_USERNAME="system-control"
CONFIG_MQTT_CLIENT_PASSWORD="3jHLhNPLcn_dPrukrpMJ"
# System Event Task
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
# Compiler Options
CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS=y
CONFIG_ESP_SYSTEM_USE_FRAME_POINTER=y
# Certificate Bundle
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=n
+7
View File
@@ -2,3 +2,10 @@
CONFIG_IDF_TARGET="esp32s3"
CONFIG_API_SERVER_HOSTNAME="system-client"
# ESP PSRAM
CONFIG_SPIRAM=y
# SPI RAM config
CONFIG_SPIRAM_SPEED=80
CONFIG_SPIRAM_USE_CAPS_ALLOC=y
+2 -6
View File
@@ -63,9 +63,5 @@
# Abenddämmerung (Violett → Blau)
80,45,95,0,115,250
60,40,100,0,110,250
45,35,95,0,105,250
30,30,85,0,100,250
25,30,75,0,95,250
20,25,65,0,90,250
15,20,50,0,85,250
45,35,78,0,100,250
20,25,55,0,88,250
1 # Nacht (Tiefblau/Dunkelblau)
63
64
65
66
67
Binary file not shown.
+1
View 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

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<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="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>System Control</title>
<script type="module" crossorigin src="/index-YHhIjoLo.js"></script>
<link rel="modulepreload" crossorigin href="/vendor-CwcuF_np.js">
<link rel="stylesheet" crossorigin href="/vendor-CbWpK_cD.css">
<link rel="stylesheet" crossorigin href="/index-BfY4NlvY.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"svelte"
],
"permissions": {
"allow": [
"mcp__svelte__svelte-autofixer"
]
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Debug Svelte (Chrome)",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"preLaunchTask": "npm: dev"
},
{
"type": "msedge",
"request": "launch",
"name": "Debug Svelte (Edge)",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"preLaunchTask": "npm: dev"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "dev",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": ".*VITE.*ready in.*",
"endsPattern": ".*VITE.*ready in.*"
}
},
"presentation": {
"focus": false,
"panel": "shared",
"showReuseMessage": false,
"clear": true
}
}
]
}
+16
View File
@@ -21,3 +21,19 @@ You MUST use this tool whenever writing Svelte code before sending it to the use
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
# Rolle: Senior Software Architect & Developer
Du agierst ab sofort als erfahrener Senior Software Developer und Architekt. Dein primärer Fokus liegt stets auf sauberer Architektur, Wartbarkeit, Skalierbarkeit und Best Practices, bevor auch nur eine Zeile Code geschrieben wird.
## Deine Prinzipien:
1. **Denke in Systemen, nicht in Snippets:** Bevor du ein Problem löst oder Code generierst, analysiere, wie sich die Änderung in die bestehende Architektur (z. B. SvelteKit, Backend-Services) einfügt.
2. **Hinterfrage Anforderungen (Push Back):** Wenn eine vom Nutzer vorgeschlagene Lösung architektonisch unsauber ist (z. B. "Quick & Dirty" Workarounds, enge Kopplung, Verletzung von SOLID-Prinzipien), weise darauf hin und schlage eine bessere, nachhaltigere Alternative vor.
3. **Trennung von Verantwortlichkeiten (SoC):** Achte peinlich genau darauf, dass UI-Logik, State-Management und Business-Logik sauber getrennt bleiben.
4. **Zukunftssicherheit:** Schreibe Code, der auch in 6 Monaten von anderen Entwicklern noch leicht verstanden und erweitert werden kann.
5. **Erkläre das "Warum":** Wenn du Code refactorst oder vorschlägst, erkläre immer die architektonische Entscheidung dahinter (z.B. "Ich habe dies in einen eigenen Service ausgelagert, um eine zirkuläre Abhängigkeit zu vermeiden...").
## Workflow bei neuen Features:
- Skizziere zuerst kurz den architektonischen Ansatz (z.B. Datenfluss, State-Management-Strategie).
- Schreibe erst danach den eigentlichen Code.
- Berücksichtige Aspekte wie Performance, Fehlerbehandlung (Error Handling) und Edge Cases.
+45
View File
@@ -0,0 +1,45 @@
# Matter over Thread Architecture
This document outlines the architectural decision and technical implementation strategy for integrating external end devices (e.g., display-less components like lighthouses, signals) into the System Control ecosystem.
## 1. Architectural Decision: Why Matter over Thread?
We have decided to use **Matter over Thread** as the standard communication protocol between the main controller (ESP) and external end devices.
### Key Benefits
* **Standardization:** Matter provides a standardized application layer (Clusters and Endpoints). A "light" or "blinking" function is mapped to standard clusters (e.g., `OnOff Cluster`), meaning the UI and backend do not need custom JSON parsing for every new device type.
* **Ecosystem Compatibility (Multi-Admin):** Matter's Multi-Admin feature allows a single end device to be controlled by multiple controllers simultaneously. This means a device can be paired to the ESP's web UI **and** directly to Apple Home or Google Home at the same time.
* **Robust Infrastructure:** Thread provides a self-healing IPv6 mesh network. The ESP acts as the Thread Border Router and Matter Commissioner.
## 2. System Roles
### Main Controller (ESP)
1. **Thread Border Router / Commissioner:** The ESP creates and manages the Thread network. It exposes a "Permit Join" window (accessible via the Web UI) to allow new, display-less devices to join the network.
2. **Matter Controller:** The ESP acts as a Matter Controller. Once a device is on the Thread network, the ESP discovers its capabilities (Clusters) and exposes them to the Web UI for configuration and control.
### End Devices (e.g., Lighthouse)
* Act as standard **Matter Accessories**.
* Join the Thread network during the "Permit Join" window using their setup code (PSKd).
* Expose their capabilities as standard Matter endpoints (e.g., Endpoint 1: Light, Endpoint 2: Blinking feature).
## 3. UI Workflow
The web interface separates network infrastructure from application control:
1. **System Tab (Infrastructure):** Used to open the Thread network ("Pair Devices" / Permit Join) so that new devices can receive an IP address and join the mesh.
2. **Configuration Tab -> Devices (Discovery):** Used to discover Matter devices that have joined the network, authenticate them via their Setup PIN, and map them to the system database.
3. **Control Tab (Application):** Dynamically displays controls (buttons, toggles) based on the discovered Matter clusters of the paired devices.
## 4. Development and Certification Notes (DIY / Hobby)
For private, DIY, and development purposes, **no official CSA (Connectivity Standards Alliance) certification is required.**
We utilize **Test Vendor IDs (VID)** (e.g., `0xFFF1`) and **Test Product IDs (PID)**.
### Ecosystem Behavior with Test Certificates
* **Apple Home (iOS):** Apple allows pairing of uncertified Matter accessories. During the pairing process, iOS will display a warning ("This accessory is uncertified"). The user can simply click **"Add anyway"** to bypass this and use the device normally.
* **Google Home:** Google requires explicit permission to add uncertified devices. Developers must log into the free **Google Home Developer Console** and register their Test VID and PID. Once registered, the Google Home app on the developer's smartphone will allow pairing without errors.
* **Our ESP Controller:** The ESP Matter SDK will be configured to accept test certificates during the Attestation phase (PASE/CASE), ensuring seamless pairing without warnings.
---
*Documented to ensure a shared understanding of the IoT communication strategy within the project team.*
+53 -13
View File
@@ -10,10 +10,12 @@
"@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"svelte": "^5.53.5",
@@ -879,7 +881,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1182,6 +1183,33 @@
"node": ">= 10"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
@@ -1230,7 +1258,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
@@ -1352,7 +1379,6 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -1422,7 +1448,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1479,7 +1504,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -1682,7 +1706,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1782,7 +1805,6 @@
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true,
"license": "MIT"
},
"node_modules/didyoumean": {
@@ -1881,14 +1903,12 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -2151,7 +2171,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -2460,7 +2479,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/loupe": {
@@ -3087,7 +3105,6 @@
"version": "5.53.12",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz",
"integrity": "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
@@ -3111,6 +3128,18 @@
"node": ">=18"
}
},
"node_modules/svelte-french-toast": {
"version": "2.0.0-alpha.0",
"resolved": "https://registry.npmjs.org/svelte-french-toast/-/svelte-french-toast-2.0.0-alpha.0.tgz",
"integrity": "sha512-81wcVaY9UZ/0JuzLEizMSoIXqNbX7yhfTZavBuw94T3cnT2HmJ9O+qXY/c91h9FkeMwboo0KHZVmzOEQVTXDFg==",
"license": "MIT",
"dependencies": {
"svelte-writable-derived": "^3.1.1"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/svelte-spa-router": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.2.tgz",
@@ -3123,6 +3152,18 @@
"url": "https://github.com/sponsors/ItalyPaleAle"
}
},
"node_modules/svelte-writable-derived": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/svelte-writable-derived/-/svelte-writable-derived-3.1.1.tgz",
"integrity": "sha512-w4LR6/bYZEuCs7SGr+M54oipk/UQKtiMadyOhW0PTwAtJ/Ai12QS77sLngEcfBx2q4H8ZBQucc9ktSA5sUGZWw==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/pixievoltno1"
},
"peerDependencies": {
"svelte": "^3.2.1 || ^4.0.0-next.1 || ^5.0.0-next.94"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3551,7 +3592,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
+2
View File
@@ -9,6 +9,7 @@
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"svelte": "^5.53.5",
@@ -22,6 +23,7 @@
"@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2"
}
}
+5 -4
View File
@@ -3,13 +3,13 @@
import Footer from './components/footer.svelte';
import Index from './routes/index.svelte';
import Captive from './routes/captive.svelte';
import Router, { location } from 'svelte-spa-router';
import Router from 'svelte-spa-router';
import { lang } from './i18n/store';
import { Toaster } from 'svelte-french-toast';
const routes = {
'/': Index,
'/control': Index,
'/config': Index,
'/captive': Captive,
// Fallback route
'*': Index
};
@@ -19,7 +19,8 @@
});
</script>
<div class="container mx-auto lg:max-w-2xl">
<Toaster/>
<div class="container mx-auto">
<Header />
<main class="py-8">
@@ -1,14 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import Card from '../common/card.svelte';
import Button from '../common/button.svelte';
import { t } from '../../i18n/store';
import SegmentRow from './segmentRow.svelte';
import { segmentStore, type Segment } from '../../stores/configSegmentStore';
import toast from 'svelte-french-toast';
let segments = $state<Segment[]>([]);
let nextId = $state(0);
onMount(() => {
const unsub = segmentStore.subscribe((data) => {
segments = data.map((s) => ({ ...s }));
nextId = data.length;
});
segmentStore.fetchSegments();
return unsub;
});
function addSegment() {
const id = `new-${nextId++}`;
segments = [...segments, { id, name: $t('wled.segment.name').replace('{num}', String(nextId)), start: 0, leds: 1 }];
}
function findOverlap(): [string, string] | null {
const sorted = [...segments].sort((a, b) => a.start - b.start);
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
if (a.start + a.leds > b.start) {
return [a.name || `#${i + 1}`, b.name || `#${i + 2}`];
}
}
return null;
}
async function saveSegments() {
const overlap = findOverlap();
if (overlap) {
toast.error(`"${overlap[0]}" & "${overlap[1]}" überschneiden sich`);
return;
}
try {
await segmentStore.updateSegments(segments);
toast.success($t('wled.saved'));
} catch {
toast.error($t('wled.error.save'));
}
}
</script>
<Card title="wled.config.title">
<p class="text-sm -mt-3 mb-4 text-text-muted">{$t("wled.config.desc")}</p>
<p class="text-sm -mt-3 mb-4 text-text-muted">{$t('wled.config.desc')}</p>
<div class="flex justify-center items-center">
<div class="flex-1">Segmente</div>
<Button label={$t("wled.segment.add")} ariaLabel={$t("wled.segment.add")}></Button>
<div class="flex justify-center items-center mb-2">
<div class="flex-1 text-sm font-medium">{$t('wled.segments.title')}</div>
<Button label={$t('wled.segment.add')} ariaLabel={$t('wled.segment.add')} icon="" onClick={addSegment} />
</div>
<div class="overflow-y-auto max-h-64 scrollbar">
<table class="w-full table-fixed border-collapse text-left">
<colgroup>
<col />
<col class="w-24" />
<col class="w-24" />
<col class="w-12" />
</colgroup>
<thead class="sticky top-0 z-10 bg-card">
<tr class="border-b-2 border-border">
<th class="px-3 py-2 text-sm">{$t('wled.segment.name').replace('{num}', '')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('wled.segment.start')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('wled.segment.leds')}</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
{#each segments as segment (segment.id)}
<SegmentRow
bind:name={segment.name}
bind:start={segment.start}
bind:leds={segment.leds}
onDelete={() => { segments = segments.filter((s) => s.id !== segment.id); }}
/>
{/each}
{#if segments.length === 0}
<tr>
<td colspan="3" class="px-3 py-4 text-sm text-center text-text-muted">
<div>{$t('wled.segments.empty.title')}</div>
<div class="text-xs mt-1">{$t('wled.segments.empty.hint')}</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<div class="flex justify-end mt-3">
<Button label={$t('btn.save')} ariaLabel={$t('btn.save')} icon="💾" onClick={saveSegments} />
</div>
</Card>
<style>
.scrollbar {
scrollbar-color: var(--primary) var(--border);
scrollbar-width: thin;
}
.scrollbar::-webkit-scrollbar {
width: 6px;
}
.scrollbar::-webkit-scrollbar-track {
background: var(--border);
border-radius: 3px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
</style>
@@ -1,7 +1,143 @@
<script lang="ts">
import Card from '../common/card.svelte';
import { t } from '../../i18n/store';
import DropDown from '../common/dropDown.svelte';
import Button from '../common/button.svelte';
import toast from 'svelte-french-toast';
import SchemaRow from './schemaRow.svelte';
import { schemaStore, type SchemaRow as SchemaRowData } from '../../stores/configSchemaStore';
import { controlStore } from '../../stores/controlStore';
import { onMount } from 'svelte';
const schemas = [
{ value: 'schema_01.csv', label: $t('schema.name.1') },
{ value: 'schema_02.csv', label: $t('schema.name.2') },
{ value: 'schema_03.csv', label: $t('schema.name.3') }
];
let activeSchema = $state($controlStore.schema ?? 'schema_01.csv');
let activeLabel = $derived(schemas.find((s) => s.value === activeSchema)?.label ?? activeSchema);
let userSelected = $state(false);
let currentSchema = $derived($controlStore.schema);
$effect(() => {
if (currentSchema && !userSelected) {
activeSchema = currentSchema;
schemaStore.fetchSchema(currentSchema).catch(() => {});
}
});
let rows = $state<SchemaRowData[]>([]);
onMount(() => {
return schemaStore.subscribe((data) => {
rows = data.map((r) => ({ ...r }));
});
});
function rowToTime(index: number): string {
const total = index * 30;
const h = Math.floor(total / 60);
const m = total % 60;
return `${h < 10 ? '0' : ''}${h}:${m < 10 ? '0' : ''}${m}`;
}
async function loadClick() {
try {
await schemaStore.fetchSchema(activeSchema);
toast.success($t('schema.loaded').replace('{file}', activeLabel));
} catch {
toast.error($t('schema.demo'));
}
}
async function saveClick() {
try {
await schemaStore.saveSchema(activeSchema, rows);
toast.success($t('schema.saved').replace('{file}', activeLabel));
} catch {
toast.error($t('error'));
}
}
</script>
<Card title="schema.editor.title">
<div class="space-y-2">
<div class="flex items-center justify-center cursor-pointer space-x-2">
<DropDown
id="active-schema"
options={schemas}
bind:value={activeSchema}
onchange={() => (userSelected = true)}
/>
<Button
label={$t('btn.load')}
ariaLabel={$t('btn.load')}
onClick={loadClick}
icon="🔄" />
<Button
label={$t('btn.save')}
ariaLabel={$t('btn.save')}
onClick={saveClick}
icon="💾" />
</div>
<div class="overflow-auto max-h-80 scrollbar">
<table class="w-full table-fixed border-collapse text-left">
<colgroup>
<col class="w-14" />
<col class="w-14" />
<col />
<col />
<col />
<col />
</colgroup>
<thead class="sticky top-0 z-10 bg-card">
<tr class="border-b-2 border-border">
<th class="px-3 py-2 text-sm">{$t('schema.header.time')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.color')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.red')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.green')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.blue')}</th>
<th class="px-3 py-2 text-sm text-center">{$t('schema.header.brightness')}</th>
</tr>
</thead>
<tbody>
{#each rows as row, i}
<SchemaRow
time={rowToTime(i)}
bind:r={row.r}
bind:g={row.g}
bind:b={row.b}
bind:brightness={row.brightness}
/>
{/each}
{#if rows.length === 0}
<tr>
<td colspan="6" class="px-3 py-4 text-sm text-center text-text-muted">
{$t('schema.loading')}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>
</Card>
<style>
.scrollbar {
scrollbar-color: var(--primary) var(--border);
scrollbar-width: thin;
}
.scrollbar::-webkit-scrollbar {
width: 6px;
}
.scrollbar::-webkit-scrollbar-track {
background: var(--border);
border-radius: 3px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts">
interface Props {
time: string;
r: number;
g: number;
b: number;
brightness: number;
}
let { time, r = $bindable(), g = $bindable(), b = $bindable(), brightness = $bindable() }: Props = $props();
function clamp(v: number): number {
return Math.min(255, Math.max(0, Math.round(v)));
}
</script>
<tr class="group">
<td class="px-3 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-l border-border">{time}</td>
<td class="px-2 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-border">
<div class="h-6 rounded" style="background: rgb({r},{g},{b});"></div>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={r}
oninput={() => r = clamp(r)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={g}
oninput={() => g = clamp(g)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number" min="0" max="255"
bind:value={b}
oninput={() => b = clamp(b)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-r border-border">
<input
type="number" min="0" max="255"
bind:value={brightness}
oninput={() => brightness = clamp(brightness)}
class="w-14 px-1 py-0.5 text-sm text-center rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
</tr>
<style>
:global(input.no-spinner::-webkit-inner-spin-button),
:global(input.no-spinner::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:global(input.no-spinner) {
-moz-appearance: textfield;
}
</style>
@@ -0,0 +1,54 @@
<script lang="ts">
interface Props {
name: string;
start: number;
leds: number;
onDelete: () => void;
}
let { name = $bindable(), start = $bindable(), leds = $bindable(), onDelete }: Props = $props();
</script>
<tr class="group">
<td class="px-3 py-2 text-sm bg-bg-secondary group-hover:bg-accent border-y border-l border-border">
<input
type="text"
bind:value={name}
class="w-full px-1 py-0.5 text-sm rounded border border-border bg-input focus:outline-none focus:border-primary"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number"
min="0"
bind:value={start}
class="w-16 px-1 py-0.5 text-sm text-right rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-3 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-border">
<input
type="number"
min="1"
bind:value={leds}
class="w-16 px-1 py-0.5 text-sm text-right rounded border border-border bg-input focus:outline-none focus:border-primary no-spinner"
/>
</td>
<td class="px-1 py-2 text-sm text-center bg-bg-secondary group-hover:bg-accent border-y border-r border-border">
<button
onclick={onDelete}
aria-label="delete"
class="flex items-center justify-center cursor-pointer px-1 py-1 bg-card rounded-lg text-text-muted text-sm transition-all border-solid border-border border-x border-y hover:border-primary"
>🗑️</button>
</td>
</tr>
<style>
:global(input.no-spinner::-webkit-inner-spin-button),
:global(input.no-spinner::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:global(input.no-spinner) {
-moz-appearance: textfield;
}
</style>
@@ -31,9 +31,9 @@
<footer class="px-0 py-4">
<hr class="border-0 mb-4 border-t-2 border-solid" />
<div class="flex justify-center items-center relative">
<div class="flex justify-between items-center">
<p class="m-0">© {romanYear} by mars3142</p>
<p class="absolute right-0 m-0 text-sm text-gray-500">
<p class="m-0 text-sm text-gray-500">
v{__APP_VERSION__} ({__COMMIT_HASH__})
</p>
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import ThreadProvisioning from "./threadProvisioning.svelte";
</script>
<div class="space-y-6">
<ThreadProvisioning />
</div>
@@ -0,0 +1,66 @@
<script lang="ts">
import { t } from '../../i18n/store';
import { threadStore } from '../../stores/threadStore';
import Card from '../common/card.svelte';
import Button from '../common/button.svelte';
function handlePermitJoin() {
threadStore.startPermitJoin(60); // 60 seconds
}
</script>
<Card title="thread.title">
<p class="text-sm text-text-muted mb-4">
{$t('thread.desc')}
</p>
<!-- Network Info Section -->
{#if $threadStore.networkInfo}
<div class="mb-4 p-4 bg-background rounded-lg border border-border flex flex-col gap-2">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">Status:</span>
<span class="text-sm text-success">
{$t(`thread.status.${$threadStore.networkInfo.status}`)}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">{$t('thread.network')}</span>
<span class="text-sm font-mono">{$threadStore.networkInfo.networkName}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">{$t('thread.panId')}</span>
<span class="text-sm font-mono">{$threadStore.networkInfo.panId}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm font-semibold">{$t('thread.channel')}</span>
<span class="text-sm font-mono">{$threadStore.networkInfo.channel}</span>
</div>
</div>
<!-- Commissioning Section -->
<div class="mt-6 flex flex-col gap-4">
<Button
label={$threadStore.isPermitJoinActive
? $t('thread.permitJoinActive').replace('{time}', $threadStore.permitJoinTimeLeft.toString())
: $t('thread.permitJoin')}
ariaLabel="Start Pairing"
onClick={handlePermitJoin}
/>
{#if $threadStore.isPermitJoinActive || $threadStore.joinedDevicesCount > 0}
<div class="p-3 bg-success/10 border border-success/30 rounded-lg flex items-center justify-center gap-2">
{#if $threadStore.isPermitJoinActive}
<span class="inline-block w-4 h-4 rounded-full border-2 border-success border-t-transparent animate-spin"></span>
{/if}
<span class="text-sm font-medium text-success">
{$t('thread.joinedCount').replace('{count}', $threadStore.joinedDevicesCount.toString())}
</span>
</div>
{/if}
</div>
{:else}
<div class="p-4 bg-background rounded-lg border border-border text-center text-sm text-text-muted italic">
{$t('thread.noNetwork')}
</div>
{/if}
</Card>
+55 -19
View File
@@ -22,6 +22,9 @@
"devices": "🔗 Geräte",
"scenes": "🎬 Szenen"
}
},
"system": {
"title": "🔧 System"
}
},
"wled": {
@@ -31,11 +34,13 @@
},
"segments": {
"title": "Segmente",
"empty": "Keine Segmente konfiguriert",
"empty.hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
"empty": {
"title": "Keine Segmente konfiguriert",
"hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
}
},
"segment": {
"add": " Segment hinzufügen",
"add": "Segment hinzufügen",
"name": "Segment {num}",
"leds": "Anzahl LEDs",
"start": "Start-LED",
@@ -86,7 +91,11 @@
"loading": "Schema wird geladen...",
"header": {
"time": "Zeit",
"color": "Farbe"
"color": "Farbe",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Helligkeit"
},
"loaded": "{file} erfolgreich geladen",
"saved": "{file} erfolgreich gespeichert!",
@@ -94,15 +103,19 @@
},
"scenes": {
"title": "Szenen",
"empty": "Keine Szenen definiert",
"empty.hint": "Erstelle Szenen unter Konfiguration",
"empty": {
"title": "Keine Szenen definiert",
"hint": "Erstelle Szenen unter Konfiguration"
},
"manage": {
"title": "Szenen verwalten",
"desc": "Erstelle und bearbeite Szenen für schnellen Zugriff"
},
"config": {
"empty": "Keine Szenen erstellt",
"empty.hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
"empty": {
"title": "Keine Szenen erstellt",
"hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
}
},
"activated": "\"{name}\" aktiviert",
"created": "Szene erstellt",
@@ -118,8 +131,10 @@
"devices": {
"external": "Externe Geräte",
"control": {
"empty": "Keine Geräte hinzugefügt",
"empty.hint": "Füge Geräte unter Konfiguration hinzu"
"empty": {
"title": "Keine Geräte hinzugefügt",
"hint": "Füge Geräte unter Konfiguration hinzu"
}
},
"new": {
"title": "Neue Geräte",
@@ -127,8 +142,10 @@
},
"searching": "Suche nach Geräten...",
"unpaired": {
"empty": "Keine neuen Geräte gefunden",
"empty.hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
"empty": {
"title": "Keine neuen Geräte gefunden",
"hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
}
},
"paired": {
"title": "Zugeordnete Geräte",
@@ -152,11 +169,15 @@
"config": {
"title": "WLAN Konfiguration"
},
"ssid": "WLAN Name (SSID)",
"ssid.placeholder": "Netzwerkname eingeben",
"password": "WLAN Passwort",
"password.short": "Passwort",
"password.placeholder": "Passwort eingeben",
"ssid": {
"title": "WLAN Name (SSID)",
"placeholder": "Netzwerkname eingeben"
},
"password": {
"title": "WLAN Passwort",
"short": "Passwort",
"placeholder": "Passwort eingeben"
},
"available": "Verfügbare Netzwerke",
"scan": {
"hint": "Nach Netzwerken suchen...",
@@ -186,6 +207,21 @@
"secure": "Gesichert",
"open": "Offen"
},
"thread": {
"title": "Thread Netzwerk",
"desc": "Dieses Gerät ist der Thread-Master. Öffne das Netzwerk, um neue Geräte ohne Display anzulernen.",
"network": "Netzwerk:",
"channel": "Kanal:",
"panId": "PAN ID:",
"status": {
"active": "Aktiv",
"offline": "Offline"
},
"permitJoin": "Geräte anlernen",
"permitJoinActive": "Anlernen aktiv ({time}s)",
"joinedCount": "{count} Gerät(e) erfolgreich verbunden",
"noNetwork": "Kein aktives Thread-Netzwerk gefunden."
},
"modal": {
"color": {
"title": "Farbe wählen"
@@ -212,8 +248,8 @@
},
"btn": {
"scan": "🔍 Suchen",
"save": "💾 Speichern",
"load": "🔄 Laden",
"save": "Speichern",
"load": "Laden",
"cancel": "Abbrechen",
"apply": "Übernehmen",
"new": {
+55 -19
View File
@@ -22,6 +22,9 @@
"devices": "🔗 Devices",
"scenes": "🎬 Scenes"
}
},
"system": {
"title": "🔧 System"
}
},
"wled": {
@@ -31,11 +34,13 @@
},
"segments": {
"title": "Segments",
"empty": "No segments configured",
"empty.hint": "Click \"Add Segment\" to create a segment"
"empty": {
"title": "No segments configured",
"hint": "Click \"Add Segment\" to create a segment"
}
},
"segment": {
"add": " Add Segment",
"add": "Add Segment",
"name": "Segment {num}",
"leds": "Number of LEDs",
"start": "Start LED",
@@ -86,7 +91,11 @@
"loading": "Loading schema...",
"header": {
"time": "Time",
"color": "Color"
"color": "Color",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Brightness"
},
"loaded": "{file} loaded successfully",
"saved": "{file} saved successfully!",
@@ -94,15 +103,19 @@
},
"scenes": {
"title": "Scenes",
"empty": "No scenes defined",
"empty.hint": "Create scenes in settings",
"empty": {
"title": "No scenes defined",
"hint": "Create scenes in settings"
},
"manage": {
"title": "Manage Scenes",
"desc": "Create and edit scenes for quick access"
},
"config": {
"empty": "No scenes created",
"empty.hint": "Click \"New Scene\" to create a scene"
"empty": {
"title": "No scenes created",
"hint": "Click \"New Scene\" to create a scene"
}
},
"activated": "\"{name}\" activated",
"created": "Scene created",
@@ -118,8 +131,10 @@
"devices": {
"external": "External Devices",
"control": {
"empty": "No devices added",
"empty.hint": "Add devices in settings"
"empty": {
"title": "No devices added",
"hint": "Add devices in settings"
}
},
"new": {
"title": "New Devices",
@@ -127,8 +142,10 @@
},
"searching": "Searching for devices...",
"unpaired": {
"empty": "No new devices found",
"empty.hint": "Press \"Scan devices\" to search for Matter devices"
"empty": {
"title": "No new devices found",
"hint": "Press \"Scan devices\" to search for Matter devices"
}
},
"paired": {
"title": "Paired Devices",
@@ -152,11 +169,15 @@
"config": {
"title": "WiFi Configuration"
},
"ssid": "WiFi Name (SSID)",
"ssid.placeholder": "Enter network name",
"password": "WiFi Password",
"password.short": "Password",
"password.placeholder": "Enter password",
"ssid": {
"title": "WiFi Name (SSID)",
"placeholder": "Enter network name"
},
"password": {
"title": "WiFi Password",
"short": "Password",
"placeholder": "Enter password"
},
"available": "Available Networks",
"scan": {
"hint": "Search for networks...",
@@ -186,6 +207,21 @@
"secure": "Secured",
"open": "Open"
},
"thread": {
"title": "Thread Network",
"desc": "This device is the Thread Master. Open the network to pair new display-less devices.",
"network": "Network:",
"channel": "Channel:",
"panId": "PAN ID:",
"status": {
"active": "Active",
"offline": "Offline"
},
"permitJoin": "Pair Devices",
"permitJoinActive": "Pairing active ({time}s)",
"joinedCount": "{count} device(s) successfully paired",
"noNetwork": "No active Thread network found."
},
"modal": {
"color": {
"title": "Choose Color"
@@ -212,8 +248,8 @@
},
"btn": {
"scan": "🔍 Scan",
"save": "💾 Save",
"load": "🔄 Load",
"save": "Save",
"load": "Load",
"cancel": "Cancel",
"apply": "Apply",
"new": {
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { t } from '../i18n/store';
import Card from '../components/common/card.svelte';
let ssid = $state('');
let password = $state('');
let showPassword = $state(false);
let statusMessage = $state('');
let statusType = $state<'success' | 'error' | 'info' | ''>('');
let countdownInterval: ReturnType<typeof setInterval> | null = null;
let canConnect = $derived(ssid.trim().length > 0 && password.length > 0);
function togglePassword() {
showPassword = !showPassword;
}
function showStatus(message: string, type: 'success' | 'error' | 'info') {
statusMessage = message;
statusType = type;
if (type !== 'info') {
setTimeout(() => {
statusMessage = '';
statusType = '';
}, 5000);
}
}
async function saveWifi() {
if (!ssid.trim()) {
showStatus($t('wifi.error.ssid'), 'error');
return;
}
showStatus($t('common.loading'), 'info');
try {
const response = await fetch('/api/wifi/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid.trim(), password })
});
if (response.ok) {
showStatus($t('wifi.saved'), 'success');
let countdown = 10;
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
const text = $t('captive.connecting').replace('{seconds}', String(countdown));
showStatus(text, 'success');
countdown--;
if (countdown < 0) {
if (countdownInterval) clearInterval(countdownInterval);
showStatus($t('captive.done'), 'success');
}
}, 1000);
} else {
const errorData = await response.json().catch(() => ({}));
showStatus($t('error') + ': ' + (errorData.error || $t('wifi.error.save')), 'error');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
showStatus($t('error') + ': ' + message, 'error');
}
}
</script>
<Card title="captive.subtitle">
<div class="mb-4">
<label for="ssid" class="block mb-2 text-sm font-medium text-text-muted">
{$t('wifi.ssid.title')}
</label>
<input
type="text"
id="ssid"
bind:value={ssid}
placeholder={$t('wifi.ssid.placeholder')}
class="w-full px-4 py-3 border-2 border-border rounded-lg bg-input text-text text-base focus:outline-none focus:border-success transition-colors"
/>
</div>
<div class="mb-4">
<label for="password" class="block mb-2 text-sm font-medium text-text-muted">
{$t('wifi.password.title')}
</label>
<div class="relative flex items-center">
<input
type={showPassword ? 'text' : 'password'}
id="password"
bind:value={password}
placeholder={$t('wifi.password.placeholder')}
class="w-full px-4 py-3 pr-12 border-2 border-border rounded-lg bg-input text-text text-base focus:outline-none focus:border-success transition-colors"
/>
<button
type="button"
onclick={togglePassword}
aria-label="Toggle password visibility"
class="absolute right-3 text-xl text-text-muted cursor-pointer bg-transparent border-none p-1 transition-colors hover:text-text"
>
{showPassword ? '🙈' : '👁️'}
</button>
</div>
</div>
<div class="mt-5">
<button
onclick={saveWifi}
disabled={!canConnect}
class="w-full py-3 px-5 rounded-lg text-base font-semibold cursor-pointer transition-all flex items-center justify-center gap-2 min-h-12 touch-manipulation bg-success text-white hover:opacity-90 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{$t('captive.connect')}
</button>
</div>
{#if statusMessage}
<div
class="mt-3 text-center rounded-lg px-4 py-3 text-sm border {statusType === 'success'
? 'bg-success/15 border-success text-success'
: statusType === 'error'
? 'bg-error/15 border-error text-error'
: 'bg-accent border-accent/50 text-text'}"
>
{statusMessage}
</div>
{/if}
<div class="bg-accent rounded-lg px-4 py-3 mt-5 text-sm text-text-muted">
<strong class="text-text">{$t('captive.note.title')}</strong>
{$t('captive.note.text')}
</div>
</Card>
+15 -4
View File
@@ -5,18 +5,22 @@
import { controlStore } from "../stores/controlStore";
import ControlTab from "../components/controlTab/controlTab.svelte";
import ConfigTab from "../components/configTab/configTab.svelte";
import SystemTab from "../components/systemTab/systemTab.svelte";
import TabButton from "../components/common/tabButton.svelte";
import TabBar from "../components/common/tabBar.svelte";
type Tab = "control" | "config";
type Tab = "control" | "config" | "system";
const tabToPath: Record<Tab, string> = {
control: "/control",
config: "/config"
config: "/config",
system: "/system"
};
function pathToTab(path: string): Tab {
return path === "/config" ? "config" : "control";
if (path === "/config") return "config";
if (path === "/system") return "system";
return "control";
}
let activeTab = $derived(pathToTab($location));
@@ -46,12 +50,19 @@
label={$t("tab.config.title")}
onClick={() => setTab("config")}
/>
<TabButton
active={activeTab === "system"}
label={$t("tab.system.title")}
onClick={() => setTab("system")}
/>
</TabBar>
<div class="tab-content">
{#if activeTab === "control"}
<ControlTab />
{:else}
{:else if activeTab === "config"}
<ConfigTab />
{:else}
<SystemTab />
{/if}
</div>
@@ -0,0 +1,57 @@
import { writable } from 'svelte/store';
import { baseUrl } from '../utils/apiClient';
import { createLogger } from '../utils/logger';
const log = createLogger('configSchemaStore');
export interface SchemaRow {
r: number;
g: number;
b: number;
w: number;
brightness: number;
saturation: number;
}
function parseCSV(csv: string): SchemaRow[] {
return csv
.trim()
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => {
const [r, g, b, w, brightness, saturation] = line.split(',').map(Number);
return { r, g, b, w, brightness, saturation };
});
}
function toCSV(rows: SchemaRow[]): string {
return rows.map((row) => `${row.r},${row.g},${row.b},${row.w},${row.brightness},${row.saturation}`).join('\n');
}
function createSchemaStore() {
const { subscribe, set } = writable<SchemaRow[]>([]);
async function fetchSchema(filename: string): Promise<void> {
log.debug('Loading schema', { filename });
const res = await fetch(`${baseUrl}/api/schema/${filename}`);
if (!res.ok) throw new Error(`Failed to load schema: ${res.status}`);
const text = await res.text();
set(parseCSV(text));
log.debug('Schema loaded', { filename });
}
async function saveSchema(filename: string, rows: SchemaRow[]): Promise<void> {
log.debug('Saving schema', { filename });
const res = await fetch(`${baseUrl}/api/schema/${filename}`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: toCSV(rows)
});
if (!res.ok) throw new Error(`Failed to save schema: ${res.status}`);
log.debug('Schema saved', { filename });
}
return { subscribe, fetchSchema, saveSchema };
}
export const schemaStore = createSchemaStore();
+188
View File
@@ -0,0 +1,188 @@
import { writable } from 'svelte/store';
import { createLogger } from '../utils/logger';
import { requestJson, resolveHost } from '../utils/apiClient';
export interface ThreadNetworkInfo {
networkName: string;
panId: string;
channel: number;
status: 'offline' | 'active';
}
export interface ThreadState {
networkInfo?: ThreadNetworkInfo;
isPermitJoinActive: boolean;
permitJoinTimeLeft: number;
joinedDevicesCount: number;
}
const DEFAULT_THREAD_STATE: ThreadState = {
isPermitJoinActive: false,
permitJoinTimeLeft: 0,
joinedDevicesCount: 0
};
const STATUS_ENDPOINT = '/api/thread/status';
const PERMIT_JOIN_ENDPOINT = '/api/thread/permit-join';
const WS_ENDPOINT = '/ws';
const WS_RECONNECT_DELAY_MS = 3000;
const isBrowser = typeof window !== 'undefined';
const buildWebSocketUrl = (host: string) => {
if (!isBrowser) return '';
const wsProtocol = window.location.protocol === 'https:' && !import.meta.env.DEV ? 'wss:' : 'ws:';
return `${wsProtocol}//${host}${WS_ENDPOINT}`;
};
const parseJson = (raw: string): any => {
try {
return JSON.parse(raw);
} catch {
return null;
}
};
const createThreadStore = () => {
const log = createLogger('threadStore');
const store = writable<ThreadState>({ ...DEFAULT_THREAD_STATE });
const { subscribe: internalSubscribe, set, update } = store;
const host = resolveHost();
const wsUrl = buildWebSocketUrl(host);
let ws: WebSocket | null = null;
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let shouldReconnect = true;
let subscriberCount = 0;
const applyState = (nextState: Partial<ThreadState>) => {
update(current => ({ ...current, ...nextState }));
};
const clearReconnectTimer = () => {
if (!wsReconnectTimer) return;
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
};
const scheduleReconnect = () => {
if (!shouldReconnect || wsReconnectTimer) return;
log.info('Scheduling WebSocket reconnect', { delayMs: WS_RECONNECT_DELAY_MS });
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
connectWebSocket();
}, WS_RECONNECT_DELAY_MS);
};
async function fetchState() {
try {
const data = await requestJson<Partial<ThreadState>>(STATUS_ENDPOINT);
applyState(data);
} catch (error) {
log.error('Failed to fetch initial thread state', error);
}
}
async function startPermitJoin(durationSeconds: number = 60) {
try {
// Optimistic UI update
applyState({
isPermitJoinActive: true,
permitJoinTimeLeft: durationSeconds,
joinedDevicesCount: 0
});
await requestJson(PERMIT_JOIN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration: durationSeconds })
});
} catch (error) {
log.error('Failed to start permit join', error);
applyState({ isPermitJoinActive: false, permitJoinTimeLeft: 0 });
}
}
function connectWebSocket() {
if (!isBrowser || ws || !wsUrl) return;
log.info('Connecting WebSocket', { url: wsUrl });
const socket = new WebSocket(wsUrl);
ws = socket;
socket.onopen = () => {
clearReconnectTimer();
log.info('WebSocket connected');
};
socket.onmessage = (event) => {
const message = parseJson(event.data);
if (!message || !message.type) return;
if (message.type === 'thread_permit_join_status') {
log.debug('Received permit join status');
applyState({
isPermitJoinActive: message.active,
permitJoinTimeLeft: message.timeLeft || 0
});
} else if (message.type === 'thread_device_joined') {
log.debug('A new device joined the Thread network!');
update(state => ({
...state,
joinedDevicesCount: state.joinedDevicesCount + 1
}));
} else if (message.type === 'thread_network_status') {
log.debug('Received thread network status update');
applyState({ networkInfo: message.networkInfo });
}
};
socket.onclose = () => {
if (ws === socket) ws = null;
log.warn('WebSocket closed');
scheduleReconnect();
};
socket.onerror = () => {
log.error('WebSocket error');
socket.close();
};
}
function disconnectWebSocket() {
shouldReconnect = false;
clearReconnectTimer();
ws?.close();
ws = null;
}
const subscribe: typeof store.subscribe = (run, invalidate) => {
subscriberCount += 1;
if (subscriberCount === 1) {
log.debug('First subscriber attached - starting WebSocket');
shouldReconnect = true;
connectWebSocket();
fetchState();
}
const unsubscribe = internalSubscribe(run, invalidate);
return () => {
unsubscribe();
subscriberCount -= 1;
if (subscriberCount === 0) {
log.debug('Last subscriber removed - stopping WebSocket');
disconnectWebSocket();
}
};
};
return {
subscribe,
fetchState,
startPermitJoin
};
};
export const threadStore = createThreadStore();
+4 -1
View File
@@ -1,5 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess()
preprocess: vitePreprocess(),
vitePlugin: {
inspector: true
}
};