latest website iteration (including FW code fixes for it)

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-04-07 20:49:11 +02:00
parent 11814545d9
commit 7678afa5ce
37 changed files with 840 additions and 108 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 // 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_err_t api_wled_config_get_handler(httpd_req_t *req)
{ {
ESP_LOGI(TAG, "GET /api/wled/config"); ESP_LOGI(TAG, "GET /api/wled/config");
extern led_segment_t segments[LED_SEGMENT_MAX_LEN]; extern led_segment_t segments[LED_SEGMENT_MAX_LEN];
extern size_t segment_count; extern size_t segment_count;
size_t required_size = sizeof(segments) * segment_count;
cJSON *json = cJSON_CreateObject(); cJSON *json = cJSON_CreateObject();
persistence_manager_t pm; persistence_manager_t pm;
if (persistence_manager_init(&pm, "led_config") == ESP_OK) 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); 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); persistence_manager_deinit(&pm);
qsort(segments, segment_count, sizeof(led_segment_t), compare_segments_by_start);
cJSON *segments_arr = cJSON_CreateArray(); cJSON *segments_arr = cJSON_CreateArray();
for (uint8_t i = 0; i < segment_count; ++i) 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); cJSON_Delete(json);
qsort(segments, segment_count, sizeof(led_segment_t), compare_segments_by_start);
persistence_manager_t pm; persistence_manager_t pm;
if (persistence_manager_init(&pm, "led_config") == ESP_OK) 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; const char *uri = req->uri;
wifi_mode_t mode = 0; wifi_mode_t mode = 0;
esp_wifi_get_mode(&mode); 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 (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA)
{ {
if (strcmp(uri, "/") == 0 || strcmp(uri, "/index.html") == 0) 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 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); ESP_LOGI(TAG, "Captive portal detection: %s", req->uri);
// Serve captive.html directly (status 200, text/html) httpd_resp_set_status(req, "302 Found");
const char *base_path = CONFIG_API_SERVER_STATIC_FILES_PATH; httpd_resp_set_hdr(req, "Location", "/#/captive");
char filepath[256]; httpd_resp_send(req, NULL, 0);
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);
return ESP_OK; return ESP_OK;
} }
@@ -97,7 +97,11 @@ void wifi_manager_init()
s_wifi_event_group = xEventGroupCreate(); s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init()); 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 // Default WiFi Station
esp_netif_create_default_wifi_sta(); esp_netif_create_default_wifi_sta();
@@ -4,5 +4,6 @@ idf_component_register(SRCS
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
REQUIRES REQUIRES
mercedes mercedes
simulator
u8g2 u8g2
) )
@@ -1,6 +1,9 @@
#include "hermes/screensaver/clock_screensaver.h" #include "hermes/screensaver/clock_screensaver.h"
#include "simulator.h"
#include <ctime> #include <ctime>
#include <cstring>
ClockScreensaver::ClockScreensaver(u8g2_t *u8g2) : m_u8g2(u8g2) 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) 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; time_t rawtime;
struct tm *timeinfo; struct tm *timeinfo;
time(&rawtime); time(&rawtime);
@@ -399,6 +399,7 @@ void stop_simulation_task(void)
{ {
TaskHandle_t handle_to_delete = simulation_task_handle; TaskHandle_t handle_to_delete = simulation_task_handle;
simulation_task_handle = NULL; simulation_task_handle = NULL;
time = NULL;
xSemaphoreGive(simulation_mutex); xSemaphoreGive(simulation_mutex);
// Check if the task still exists before deleting it // Check if the task still exists before deleting it
+7
View File
@@ -11,6 +11,7 @@
#include <esp_log.h> #include <esp_log.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <esp_system.h>
#include <nvs_flash.h> #include <nvs_flash.h>
#include <sdkconfig.h> #include <sdkconfig.h>
@@ -39,6 +40,12 @@ void app_main(void)
gpio_set_level(WIFI_ANT_CONFIG, 1); // HIGH gpio_set_level(WIFI_ANT_CONFIG, 1); // HIGH
#endif #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 // Initialize NVS
esp_err_t err = nvs_flash_init(); esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) 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_STARTUP_DELAY=y
CONFIG_LWIP_SNTP_MAXIMUM_STARTUP_DELAY=5000 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 # HTTP Server WebSocket Support
CONFIG_HTTPD_WS_SUPPORT=y 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_USERNAME="system-control"
CONFIG_MQTT_CLIENT_PASSWORD="3jHLhNPLcn_dPrukrpMJ" CONFIG_MQTT_CLIENT_PASSWORD="3jHLhNPLcn_dPrukrpMJ"
# System Event Task
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
# Compiler Options # Compiler Options
CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS=y CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS=y
CONFIG_ESP_SYSTEM_USE_FRAME_POINTER=y
# Certificate Bundle # Certificate Bundle
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=n CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=n
+7
View File
@@ -2,3 +2,10 @@
CONFIG_IDF_TARGET="esp32s3" CONFIG_IDF_TARGET="esp32s3"
CONFIG_API_SERVER_HOSTNAME="system-client" 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) # Abenddämmerung (Violett → Blau)
80,45,95,0,115,250 80,45,95,0,115,250
60,40,100,0,110,250 45,35,78,0,100,250
45,35,95,0,105,250 20,25,55,0,88,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
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"
]
}
}
+53 -13
View File
@@ -10,10 +10,12 @@
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2" "svelte-spa-router": "^4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1", "@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"svelte": "^5.53.5", "svelte": "^5.53.5",
@@ -879,7 +881,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
@@ -1182,6 +1183,33 @@
"node": ">= 10" "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": { "node_modules/@tailwindcss/vite": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
@@ -1230,7 +1258,6 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
@@ -1352,7 +1379,6 @@
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@@ -1422,7 +1448,6 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -1479,7 +1504,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -1682,7 +1706,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -1782,7 +1805,6 @@
"version": "5.6.4", "version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/didyoumean": { "node_modules/didyoumean": {
@@ -1881,14 +1903,12 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
@@ -2151,7 +2171,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
@@ -2460,7 +2479,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/loupe": { "node_modules/loupe": {
@@ -3087,7 +3105,6 @@
"version": "5.53.12", "version": "5.53.12",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz",
"integrity": "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==", "integrity": "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
@@ -3111,6 +3128,18 @@
"node": ">=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": { "node_modules/svelte-spa-router": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.2.tgz", "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" "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": { "node_modules/tailwindcss": {
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -3551,7 +3592,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
} }
} }
+2
View File
@@ -9,6 +9,7 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1", "@sveltejs/vite-plugin-svelte": "^6.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"svelte": "^5.53.5", "svelte": "^5.53.5",
@@ -22,6 +23,7 @@
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"svelte-french-toast": "^2.0.0-alpha.0",
"svelte-spa-router": "^4.0.2" "svelte-spa-router": "^4.0.2"
} }
} }
+5 -4
View File
@@ -3,13 +3,13 @@
import Footer from './components/footer.svelte'; import Footer from './components/footer.svelte';
import Index from './routes/index.svelte'; import Index from './routes/index.svelte';
import Captive from './routes/captive.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 { lang } from './i18n/store';
import { Toaster } from 'svelte-french-toast';
const routes = { const routes = {
'/': Index, '/': Index,
'/control': Index, '/captive': Captive,
'/config': Index,
// Fallback route // Fallback route
'*': Index '*': Index
}; };
@@ -19,7 +19,8 @@
}); });
</script> </script>
<div class="container mx-auto lg:max-w-2xl"> <Toaster/>
<div class="container mx-auto">
<Header /> <Header />
<main class="py-8"> <main class="py-8">
@@ -7,4 +7,4 @@
<LedConfiguration /> <LedConfiguration />
<SchemaConfiguration /> <SchemaConfiguration />
</div> </div>
@@ -1,14 +1,118 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Card from '../common/card.svelte'; import Card from '../common/card.svelte';
import Button from '../common/button.svelte'; import Button from '../common/button.svelte';
import { t } from '../../i18n/store'; 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> </script>
<Card title="wled.config.title"> <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 justify-center items-center mb-2">
<div class="flex-1">Segmente</div> <div class="flex-1 text-sm font-medium">{$t('wled.segments.title')}</div>
<Button label={$t("wled.segment.add")} ariaLabel={$t("wled.segment.add")}></Button> <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> </div>
</Card> </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"> <script lang="ts">
import Card from '../common/card.svelte'; 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> </script>
<Card title="schema.editor.title"> <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> </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"> <footer class="px-0 py-4">
<hr class="border-0 mb-4 border-t-2 border-solid" /> <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="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__}) v{__APP_VERSION__} ({__COMMIT_HASH__})
</p> </p>
</div> </div>
+37 -19
View File
@@ -31,11 +31,13 @@
}, },
"segments": { "segments": {
"title": "Segmente", "title": "Segmente",
"empty": "Keine Segmente konfiguriert", "empty": {
"empty.hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen" "title": "Keine Segmente konfiguriert",
"hint": "Klicke auf \"Segment hinzufügen\" um ein Segment zu erstellen"
}
}, },
"segment": { "segment": {
"add": " Segment hinzufügen", "add": "Segment hinzufügen",
"name": "Segment {num}", "name": "Segment {num}",
"leds": "Anzahl LEDs", "leds": "Anzahl LEDs",
"start": "Start-LED", "start": "Start-LED",
@@ -86,7 +88,11 @@
"loading": "Schema wird geladen...", "loading": "Schema wird geladen...",
"header": { "header": {
"time": "Zeit", "time": "Zeit",
"color": "Farbe" "color": "Farbe",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Helligkeit"
}, },
"loaded": "{file} erfolgreich geladen", "loaded": "{file} erfolgreich geladen",
"saved": "{file} erfolgreich gespeichert!", "saved": "{file} erfolgreich gespeichert!",
@@ -94,15 +100,19 @@
}, },
"scenes": { "scenes": {
"title": "Szenen", "title": "Szenen",
"empty": "Keine Szenen definiert", "empty": {
"empty.hint": "Erstelle Szenen unter Konfiguration", "title": "Keine Szenen definiert",
"hint": "Erstelle Szenen unter Konfiguration"
},
"manage": { "manage": {
"title": "Szenen verwalten", "title": "Szenen verwalten",
"desc": "Erstelle und bearbeite Szenen für schnellen Zugriff" "desc": "Erstelle und bearbeite Szenen für schnellen Zugriff"
}, },
"config": { "config": {
"empty": "Keine Szenen erstellt", "empty": {
"empty.hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen" "title": "Keine Szenen erstellt",
"hint": "Klicke auf \"Neue Szene\" um eine Szene zu erstellen"
}
}, },
"activated": "\"{name}\" aktiviert", "activated": "\"{name}\" aktiviert",
"created": "Szene erstellt", "created": "Szene erstellt",
@@ -118,8 +128,10 @@
"devices": { "devices": {
"external": "Externe Geräte", "external": "Externe Geräte",
"control": { "control": {
"empty": "Keine Geräte hinzugefügt", "empty": {
"empty.hint": "Füge Geräte unter Konfiguration hinzu" "title": "Keine Geräte hinzugefügt",
"hint": "Füge Geräte unter Konfiguration hinzu"
}
}, },
"new": { "new": {
"title": "Neue Geräte", "title": "Neue Geräte",
@@ -127,8 +139,10 @@
}, },
"searching": "Suche nach Geräten...", "searching": "Suche nach Geräten...",
"unpaired": { "unpaired": {
"empty": "Keine neuen Geräte gefunden", "empty": {
"empty.hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen" "title": "Keine neuen Geräte gefunden",
"hint": "Drücke \"Geräte suchen\" um nach Matter-Geräten zu suchen"
}
}, },
"paired": { "paired": {
"title": "Zugeordnete Geräte", "title": "Zugeordnete Geräte",
@@ -152,11 +166,15 @@
"config": { "config": {
"title": "WLAN Konfiguration" "title": "WLAN Konfiguration"
}, },
"ssid": "WLAN Name (SSID)", "ssid": {
"ssid.placeholder": "Netzwerkname eingeben", "title": "WLAN Name (SSID)",
"password": "WLAN Passwort", "placeholder": "Netzwerkname eingeben"
"password.short": "Passwort", },
"password.placeholder": "Passwort eingeben", "password": {
"title": "WLAN Passwort",
"short": "Passwort",
"placeholder": "Passwort eingeben"
},
"available": "Verfügbare Netzwerke", "available": "Verfügbare Netzwerke",
"scan": { "scan": {
"hint": "Nach Netzwerken suchen...", "hint": "Nach Netzwerken suchen...",
@@ -212,8 +230,8 @@
}, },
"btn": { "btn": {
"scan": "🔍 Suchen", "scan": "🔍 Suchen",
"save": "💾 Speichern", "save": "Speichern",
"load": "🔄 Laden", "load": "Laden",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"apply": "Übernehmen", "apply": "Übernehmen",
"new": { "new": {
+37 -19
View File
@@ -31,11 +31,13 @@
}, },
"segments": { "segments": {
"title": "Segments", "title": "Segments",
"empty": "No segments configured", "empty": {
"empty.hint": "Click \"Add Segment\" to create a segment" "title": "No segments configured",
"hint": "Click \"Add Segment\" to create a segment"
}
}, },
"segment": { "segment": {
"add": " Add Segment", "add": "Add Segment",
"name": "Segment {num}", "name": "Segment {num}",
"leds": "Number of LEDs", "leds": "Number of LEDs",
"start": "Start LED", "start": "Start LED",
@@ -86,7 +88,11 @@
"loading": "Loading schema...", "loading": "Loading schema...",
"header": { "header": {
"time": "Time", "time": "Time",
"color": "Color" "color": "Color",
"red": "R",
"green": "G",
"blue": "B",
"brightness": "Brightness"
}, },
"loaded": "{file} loaded successfully", "loaded": "{file} loaded successfully",
"saved": "{file} saved successfully!", "saved": "{file} saved successfully!",
@@ -94,15 +100,19 @@
}, },
"scenes": { "scenes": {
"title": "Scenes", "title": "Scenes",
"empty": "No scenes defined", "empty": {
"empty.hint": "Create scenes in settings", "title": "No scenes defined",
"hint": "Create scenes in settings"
},
"manage": { "manage": {
"title": "Manage Scenes", "title": "Manage Scenes",
"desc": "Create and edit scenes for quick access" "desc": "Create and edit scenes for quick access"
}, },
"config": { "config": {
"empty": "No scenes created", "empty": {
"empty.hint": "Click \"New Scene\" to create a scene" "title": "No scenes created",
"hint": "Click \"New Scene\" to create a scene"
}
}, },
"activated": "\"{name}\" activated", "activated": "\"{name}\" activated",
"created": "Scene created", "created": "Scene created",
@@ -118,8 +128,10 @@
"devices": { "devices": {
"external": "External Devices", "external": "External Devices",
"control": { "control": {
"empty": "No devices added", "empty": {
"empty.hint": "Add devices in settings" "title": "No devices added",
"hint": "Add devices in settings"
}
}, },
"new": { "new": {
"title": "New Devices", "title": "New Devices",
@@ -127,8 +139,10 @@
}, },
"searching": "Searching for devices...", "searching": "Searching for devices...",
"unpaired": { "unpaired": {
"empty": "No new devices found", "empty": {
"empty.hint": "Press \"Scan devices\" to search for Matter devices" "title": "No new devices found",
"hint": "Press \"Scan devices\" to search for Matter devices"
}
}, },
"paired": { "paired": {
"title": "Paired Devices", "title": "Paired Devices",
@@ -152,11 +166,15 @@
"config": { "config": {
"title": "WiFi Configuration" "title": "WiFi Configuration"
}, },
"ssid": "WiFi Name (SSID)", "ssid": {
"ssid.placeholder": "Enter network name", "title": "WiFi Name (SSID)",
"password": "WiFi Password", "placeholder": "Enter network name"
"password.short": "Password", },
"password.placeholder": "Enter password", "password": {
"title": "WiFi Password",
"short": "Password",
"placeholder": "Enter password"
},
"available": "Available Networks", "available": "Available Networks",
"scan": { "scan": {
"hint": "Search for networks...", "hint": "Search for networks...",
@@ -212,8 +230,8 @@
}, },
"btn": { "btn": {
"scan": "🔍 Scan", "scan": "🔍 Scan",
"save": "💾 Save", "save": "Save",
"load": "🔄 Load", "load": "Load",
"cancel": "Cancel", "cancel": "Cancel",
"apply": "Apply", "apply": "Apply",
"new": { "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>
@@ -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();
+4 -1
View File
@@ -1,5 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { export default {
preprocess: vitePreprocess() preprocess: vitePreprocess(),
vitePlugin: {
inspector: true
}
}; };