latest website iteration (including FW code fixes for it)
Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
*.gz filter=lfs diff=lfs merge=lfs -text
|
||||
Vendored
+34
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Binary file not shown.
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+53
-13
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,11 +31,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 +88,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 +100,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 +128,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 +139,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 +166,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...",
|
||||
@@ -212,8 +230,8 @@
|
||||
},
|
||||
"btn": {
|
||||
"scan": "🔍 Suchen",
|
||||
"save": "💾 Speichern",
|
||||
"load": "🔄 Laden",
|
||||
"save": "Speichern",
|
||||
"load": "Laden",
|
||||
"cancel": "Abbrechen",
|
||||
"apply": "Übernehmen",
|
||||
"new": {
|
||||
|
||||
@@ -31,11 +31,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 +88,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 +100,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 +128,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 +139,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 +166,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...",
|
||||
@@ -212,8 +230,8 @@
|
||||
},
|
||||
"btn": {
|
||||
"scan": "🔍 Scan",
|
||||
"save": "💾 Save",
|
||||
"load": "🔄 Load",
|
||||
"save": "Save",
|
||||
"load": "Load",
|
||||
"cancel": "Cancel",
|
||||
"apply": "Apply",
|
||||
"new": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess()
|
||||
preprocess: vitePreprocess(),
|
||||
vitePlugin: {
|
||||
inspector: true
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user