latest website and FW changes

Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
2026-03-17 19:45:28 +01:00
parent f601990c67
commit 829e914a79
27 changed files with 1375 additions and 206 deletions
+51
View File
@@ -0,0 +1,51 @@
# Project Guidelines
## Code Style
- Keep existing C/C++ formatting as in this repo (brace-on-new-line in C files, compact designated initializers where already used).
- Use `ESP_LOGI/W/E` with a local `TAG` constant in each source file.
- For HTTP API handlers, prefer existing helper functions for response consistency: `set_cors_headers`, `send_json_response`, and `send_error_response`.
- Keep changes minimal and component-local. Avoid cross-component refactors unless explicitly requested.
## Architecture
- Firmware entry and orchestration live in `main/`.
- Business logic is split into ESP-IDF components under `components/` (notably `api-server`, `message-manager`, `persistence-manager`, `connectivity-manager`, `led-manager`).
- Web UI sources are in `website/`; static assets are served by `api-server` from `CONFIG_API_SERVER_STATIC_FILES_PATH`.
- Storage-related runtime files and schema CSVs are in `storage/`.
## Build and Test
- Default firmware build: `idf.py build`
- Flash: `idf.py -p <PORT> flash`
- Flash + monitor: `idf.py -p <PORT> flash monitor`
- Release build (from `Makefile`):
`idf.py -B build-release -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.release" -DIDF_TARGET=esp32c6 fullclean build size`
- Web UI build:
`cd website && npm install && npm run build`
- Web UI tests:
`cd website && npm run test`
## Conventions
- In `api-server`, URI handler registration order is critical:
1) specific `/api/...` handlers
2) wildcard API handlers
3) captive-portal detection handlers
4) `OPTIONS /api/*`
5) static fallback `/*` last
- In `api_server.c`, initialize WebSocket handling before API handler registration.
- Use `message_manager_post(...)` for cross-component state updates instead of direct coupling.
- Persist settings via `persistence_manager` using explicit namespaces per feature area.
## Pitfalls
- This workspace supports multiple ESP targets (`esp32s3`, `esp32c6`) with different defaults (`sdkconfig.defaults.*`). Do not assume one target's pins/settings for all builds.
- Keep `build/` artifacts out of manual edits; source of truth is under `main/`, `components/`, `storage/`, and `website/`.
- For static assets, preserve correct HTTP headers (content type, CORS, and `Content-Encoding: gzip` when serving `.gz` variants).
## Key References
- `README.md`
- `README-API.md`
- `README-captive.md`
- `components/api-server/src/api_handlers.c`
- `components/api-server/src/api_server.c`
- `components/message-manager/src/message_manager.c`
- `components/persistence-manager/src/persistence_manager.c`
- `website/package.json`
- `Makefile`
@@ -0,0 +1,25 @@
---
description: "Use when implementing or changing ESP-IDF HTTP handlers, REST routes, captive portal flows, static file serving, CORS handling, or WebSocket-adjacent API behavior in api-server."
name: "API Server Instructions"
applyTo: "components/api-server/**/*.{c,h}"
---
# API Server Guidelines
- Keep handler registration order stable in api_handlers.c:
1) specific /api routes
2) wildcard API routes
3) captive-portal detection routes
4) OPTIONS /api/*
5) static fallback /* last
- Preserve response consistency by reusing helper functions:
- set_cors_headers
- send_json_response
- send_error_response
- In api_server.c, initialize WebSocket handling before registering API handlers.
- Keep static asset behavior HTTP-correct:
- content type by original file type
- CORS headers for browser usage
- Content-Encoding: gzip when serving .gz assets
- Prefer localized changes in api-server only; avoid cross-component coupling for state changes.
- For state updates, use message_manager_post instead of direct control flow into other components.
- Add clear ESP_LOGI/W/E messages with the local TAG for new request paths and error branches.
+17
View File
@@ -0,0 +1,17 @@
---
description: "Use when editing Svelte/Vite frontend files, routing, styling, static assets, and frontend build or test scripts under website."
name: "Website Instructions"
applyTo: "website/**"
---
# Website Guidelines
- Keep changes aligned with the existing Svelte + Vite setup and current dependency stack in website/package.json.
- Validate frontend changes with project scripts:
- npm run build
- npm run test
- When changing static assets, keep compatibility with firmware static-file serving expectations:
- prefer deterministic asset names/paths used by the frontend bundle
- ensure assets are safe to serve with optional gzip variants
- Avoid introducing framework or tooling migrations unless explicitly requested.
- Keep UI behavior robust for captive-portal style access patterns (direct root load, limited network conditions).
- For API usage from frontend, preserve existing endpoint conventions under /api and avoid ad-hoc route shape changes.
+43
View File
@@ -0,0 +1,43 @@
---
description: "Generate a complete new API endpoint (handler, registration, response structure) that follows project conventions, with minimal setup required."
name: "Add API Endpoint"
argument-hint: "Endpoint path and purpose (e.g., GET /api/device/status, POST /api/scenes/create)"
agent: "agent"
---
# Add API Endpoint
Create a new API endpoint that follows this project's conventions:
**Input Requirements:**
- Endpoint path and HTTP method (GET, POST, DELETE, etc.)
- Brief description of endpoint purpose and data flow
- Response shape (JSON keys/types expected)
- Any cross-component dependencies (message_manager, persistence_manager, etc.)
**Deliverables:**
1. Handler function stub with:
- Proper ESP_LOGI entry logging
- Input validation (where applicable)
- Consistent error responses via send_error_response
- CORS headers via set_cors_headers
- JSON output via send_json_response
2. Handler registration in api_handlers_register(), placed in correct order relative to existing routes
3. Example request/response JSON
4. Any required persistence_manager namespaces or message_manager posting
**Reference Style From:**
- [components/api-server/src/api_handlers.c](components/api-server/src/api_handlers.c#L65)—api_capabilities_get_handler, api_wifi_status_handler
- [.github/instructions/api-server.instructions.md](.github/instructions/api-server.instructions.md)—handler order, helper function patterns
**Output Format:**
```c
// Handler function
esp_err_t api_YOURNAME_handler(httpd_req_t *req) {
ESP_LOGI(TAG, "...");
// implementation
}
// Registration snippet (paste into api_handlers_register)
httpd_uri_t YOURNAME = {...};
err = httpd_register_uri_handler(server, &YOURNAME);
```
+30
View File
@@ -0,0 +1,30 @@
---
description: "Validate frontend changes: build, test, and check for regressions against API expectations and existing features."
name: "Website Change Check"
argument-hint: "Summary of changes made (e.g., routing update, new component, dependency upgrade)"
agent: "agent"
---
# Website Change Check
Run a quick validation pass on frontend changes:
**Steps:**
1. **Build Check**: Run `npm run build` and report any TypeScript/Svelte errors
2. **Test Check**: Run `npm run test` and report test coverage or failures
3. **API Compatibility**: Verify endpoint usage (search codebase for /api calls) matches current routes in [README-API.md](README-API.md)
4. **Asset Integrity**: Confirm static asset paths and fetch patterns still work with firmware's static-file server (including gzip fallback)
5. **Summary**: List any breaking changes or build artifacts that need cleanup
**Context:**
- Website builds and tests must pass for firmware flashing
- Static assets are served by [api-server](components/api-server/src/api_handlers.c#L898) with potential gzip variants
- Captive portal access (AP mode) expects `/`, `/index.html` → redirect to `/captive.html`
**Reference:**
- [website/package.json](website/package.json)—build and test scripts
- [.github/instructions/website.instructions.md](.github/instructions/website.instructions.md)—frontend conventions
**Expected Output:**
- ✅ or ❌ for each check
- Specific error messages or warnings
- Suggested fixes (if any)
@@ -86,23 +86,29 @@ esp_err_t api_wifi_scan_handler(httpd_req_t *req)
{
ESP_LOGI(TAG, "GET /api/wifi/scan");
// Start WiFi scan non-blocking (async) to avoid blocking HTTP server
// The scan will complete in background, results available on next request
wifi_scan_config_t scan_config = {.ssid = NULL, .bssid = NULL, .channel = 0, .show_hidden = true};
esp_err_t err = esp_wifi_scan_start(&scan_config, true);
esp_err_t err = esp_wifi_scan_start(&scan_config, false);
if (err != ESP_OK)
{
return send_error_response(req, 500, "WiFi scan failed");
ESP_LOGD(TAG, "WiFi scan start: %s (may already be scanning)", esp_err_to_name(err));
// Continue and return cached results - don't block on error
}
// Get cached scan results (from previous scan if available)
uint16_t ap_num = 0;
esp_wifi_scan_get_ap_num(&ap_num);
wifi_ap_record_t *ap_list = heap_caps_calloc(ap_num, sizeof(wifi_ap_record_t), MALLOC_CAP_DEFAULT);
if (!ap_list)
{
return send_error_response(req, 500, "Memory allocation failed");
}
esp_wifi_scan_get_ap_records(&ap_num, ap_list);
cJSON *json = cJSON_CreateArray();
if (ap_num > 0)
{
wifi_ap_record_t *ap_list = heap_caps_calloc(ap_num, sizeof(wifi_ap_record_t), MALLOC_CAP_DEFAULT);
if (ap_list)
{
esp_wifi_scan_get_ap_records(&ap_num, ap_list);
for (int i = 0; i < ap_num; i++)
{
if (ap_list[i].ssid[0] != '\0')
@@ -115,9 +121,12 @@ esp_err_t api_wifi_scan_handler(httpd_req_t *req)
cJSON_AddItemToArray(json, entry);
}
}
free(ap_list);
}
}
char *response = cJSON_PrintUnformatted(json);
cJSON_Delete(json);
free(ap_list);
esp_err_t res = send_json_response(req, response);
free(response);
return res;
@@ -898,6 +907,7 @@ static const char *get_mime_type(const char *path)
esp_err_t api_static_file_handler(httpd_req_t *req)
{
char filepath[CONFIG_HTTPD_MAX_URI_LEN + 16];
char gz_filepath[CONFIG_HTTPD_MAX_URI_LEN + 20];
const char *uri = req->uri;
wifi_mode_t mode = 0;
@@ -927,26 +937,44 @@ esp_err_t api_static_file_handler(httpd_req_t *req)
return send_error_response(req, 400, "URI too long");
}
ESP_LOGI(TAG, "Serving static file: %s", filepath);
bool use_gzip = false;
const char *served_path = filepath;
written = snprintf(gz_filepath, sizeof(gz_filepath), "%s.gz", filepath);
if (written >= 0 && (size_t)written < sizeof(gz_filepath))
{
struct stat gz_st;
if (stat(gz_filepath, &gz_st) == 0)
{
use_gzip = true;
served_path = gz_filepath;
}
}
ESP_LOGI(TAG, "Serving static file: %s%s", served_path, use_gzip ? " (gzip)" : "");
// Check if file exists
struct stat st;
if (stat(filepath, &st) != 0)
if (stat(served_path, &st) != 0)
{
ESP_LOGW(TAG, "File not found: %s", filepath);
ESP_LOGW(TAG, "File not found: %s", served_path);
return send_error_response(req, 404, "File not found");
}
// Open and serve file
FILE *f = fopen(filepath, "r");
FILE *f = fopen(served_path, "rb");
if (f == NULL)
{
ESP_LOGE(TAG, "Failed to open file: %s", filepath);
ESP_LOGE(TAG, "Failed to open file: %s", served_path);
return send_error_response(req, 500, "Failed to open file");
}
set_cors_headers(req);
httpd_resp_set_type(req, get_mime_type(filepath));
if (use_gzip)
{
httpd_resp_set_hdr(req, "Content-Encoding", "gzip");
}
char buf[512];
size_t read_bytes;
@@ -11,7 +11,7 @@
#include <sdkconfig.h>
#include <string.h>
#define MESSAGE_QUEUE_LENGTH 16
#define MESSAGE_QUEUE_LENGTH 32
#define MESSAGE_QUEUE_ITEM_SIZE sizeof(message_t)
static const char *TAG = "message_manager";
@@ -3,6 +3,54 @@
#include <string.h>
#define TAG "persistence_manager"
#define PM_MAX_CACHED_HANDLES 8
// NVS handle cache to avoid repeated nvs_open/close operations (performance optimization)
typedef struct
{
char namespace[16];
nvs_handle_t handle;
bool in_use;
} nvs_handle_cache_entry_t;
static nvs_handle_cache_entry_t nvs_cache[PM_MAX_CACHED_HANDLES] = {0};
// Get or create a cached NVS handle for the given namespace
static esp_err_t _get_cached_nvs_handle(const char *nvs_namespace, nvs_handle_t *out_handle)
{
// Search for existing handle in cache
for (int i = 0; i < PM_MAX_CACHED_HANDLES; i++)
{
if (nvs_cache[i].in_use && strcmp(nvs_cache[i].namespace, nvs_namespace) == 0)
{
*out_handle = nvs_cache[i].handle;
ESP_LOGD(TAG, "Using cached NVS handle for namespace: %s", nvs_namespace);
return ESP_OK;
}
}
// Not found, try to create a new one
for (int i = 0; i < PM_MAX_CACHED_HANDLES; i++)
{
if (!nvs_cache[i].in_use)
{
esp_err_t err = nvs_open(nvs_namespace, NVS_READWRITE, &nvs_cache[i].handle);
if (err == ESP_OK)
{
nvs_cache[i].in_use = true;
strncpy(nvs_cache[i].namespace, nvs_namespace, sizeof(nvs_cache[i].namespace) - 1);
nvs_cache[i].namespace[sizeof(nvs_cache[i].namespace) - 1] = '\0';
*out_handle = nvs_cache[i].handle;
ESP_LOGD(TAG, "Opened and cached NVS handle for namespace: %s", nvs_namespace);
return ESP_OK;
}
return err;
}
}
ESP_LOGE(TAG, "NVS handle cache full (max %d handles)", PM_MAX_CACHED_HANDLES);
return ESP_ERR_NO_MEM;
}
esp_err_t persistence_manager_factory_reset(void)
{
@@ -19,26 +67,32 @@ esp_err_t persistence_manager_init(persistence_manager_t *pm, const char *nvs_na
{
if (!pm)
return ESP_ERR_INVALID_ARG;
strncpy(pm->nvs_namespace, nvs_namespace ? nvs_namespace : "config", sizeof(pm->nvs_namespace) - 1);
pm->nvs_namespace[sizeof(pm->nvs_namespace) - 1] = '\0';
pm->initialized = false;
esp_err_t err = nvs_open(pm->nvs_namespace, NVS_READWRITE, &pm->nvs_handle);
// Get cached NVS handle instead of opening a new one each time
esp_err_t err = _get_cached_nvs_handle(pm->nvs_namespace, &pm->nvs_handle);
if (err == ESP_OK)
{
pm->initialized = true;
ESP_LOGD(TAG, "Initialized with namespace: %s", pm->nvs_namespace);
ESP_LOGD(TAG, "Initialized with namespace: %s (cached)", pm->nvs_namespace);
return ESP_OK;
}
ESP_LOGE(TAG, "Failed to open NVS handle: %s", esp_err_to_name(err));
ESP_LOGE(TAG, "Failed to get NVS handle: %s", esp_err_to_name(err));
return err;
}
esp_err_t persistence_manager_deinit(persistence_manager_t *pm)
{
if (pm && pm->initialized)
// Handles are now cached and kept open for performance
// Only mark as uninitialized, don't close the handle
if (pm)
{
nvs_close(pm->nvs_handle);
pm->initialized = false;
ESP_LOGD(TAG, "Deinitialized (handle remains cached for reuse)");
}
return ESP_OK;
}
@@ -22,6 +22,7 @@ extern "C"
void start_simulate_night(void);
void start_simulation_task(void);
void stop_simulation_task(void);
void start_simulation_with_reload(bool force_reload);
void start_simulation(void);
#ifdef __cplusplus
}
@@ -38,6 +38,8 @@ static char *time = NULL;
static TaskHandle_t simulation_task_handle = NULL;
static SemaphoreHandle_t simulation_mutex = NULL;
static light_item_node_t *head = NULL;
static bool schema_loaded = false;
static int loaded_variant = -1;
static const interpolation_mode_t interpolation_mode = INTERPOLATION_RGB;
// Helper function: converts hhmm format to minutes of the day
@@ -140,21 +142,37 @@ void cleanup_light_items(void)
}
head = NULL;
schema_loaded = false;
loaded_variant = -1;
ESP_LOGI(TAG, "Cleaned up all light items.");
}
static void initialize_light_items(void)
static void initialize_light_items(bool force_reload)
{
cleanup_light_items();
initialize_storage();
static char filename[30];
persistence_manager_t persistence;
persistence_manager_init(&persistence, "config");
int variant = persistence_manager_get_int(&persistence, "light_variant", 1);
persistence_manager_deinit(&persistence);
bool variant_changed = (loaded_variant != variant);
bool needs_reload = force_reload || !schema_loaded || variant_changed;
if (needs_reload)
{
cleanup_light_items();
initialize_storage();
snprintf(filename, sizeof(filename), "schema_%02d.csv", variant);
load_file(filename);
persistence_manager_deinit(&persistence);
schema_loaded = true;
loaded_variant = variant;
ESP_LOGI(TAG, "Schema loaded (variant=%d, force_reload=%s)", variant, force_reload ? "true" : "false");
}
else
{
ESP_LOGD(TAG, "Schema reload skipped (variant=%d unchanged)", variant);
}
// The list is now sorted because add_light_item inserts sorted
@@ -225,7 +243,7 @@ char *get_time(void)
void start_simulate_day(void)
{
initialize_light_items();
initialize_light_items(false);
light_item_node_t *current_item = find_best_light_item_for_time(1200);
if (current_item != NULL)
@@ -238,7 +256,7 @@ void start_simulate_day(void)
void start_simulate_night(void)
{
initialize_light_items();
initialize_light_items(false);
light_item_node_t *current_item = find_best_light_item_for_time(0);
if (current_item != NULL)
@@ -267,7 +285,7 @@ void simulate_cycle(void *args)
return;
}
initialize_light_items();
initialize_light_items(false);
const int total_minutes_in_day = 24 * 60;
long delay_ms = (long)cycle_duration_minutes * 60 * 1000 / total_minutes_in_day;
@@ -398,7 +416,7 @@ void stop_simulation_task(void)
}
}
void start_simulation(void)
void start_simulation_with_reload(bool force_reload)
{
stop_simulation_task();
@@ -410,6 +428,10 @@ void start_simulation(void)
switch (mode)
{
case 0: // Simulation mode
if (force_reload)
{
initialize_light_items(true);
}
start_simulation_task();
break;
case 1: // Day mode
@@ -429,3 +451,8 @@ void start_simulation(void)
}
persistence_manager_deinit(&persistence);
}
void start_simulation(void)
{
start_simulation_with_reload(true);
}
+45 -6
View File
@@ -44,6 +44,19 @@ persistence_manager_t g_persistence_manager;
extern QueueHandle_t buttonQueue;
static TaskHandle_t display_update_task_handle = nullptr;
// Display update task - handles I2C transfer asynchronously (non-blocking main loop)
static void display_update_task(void *args)
{
while (true)
{
// Wait for render completion signal from app_task and send immediately.
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
u8g2_SendBuffer(&u8g2);
}
}
static void setup_screen(void)
{
u8g2_esp32_hal_t u8g2_esp32_hal = U8G2_ESP32_HAL_DEFAULT;
@@ -133,12 +146,30 @@ static void init_ui(void)
static void on_message_received(const message_t *msg)
{
if (msg && msg->type == MESSAGE_TYPE_SETTINGS &&
(std::strcmp(msg->data.settings.key, "light_active") == 0 ||
std::strcmp(msg->data.settings.key, "light_variant") == 0 ||
std::strcmp(msg->data.settings.key, "light_mode") == 0))
if (!msg || msg->type != MESSAGE_TYPE_SETTINGS)
{
start_simulation();
return;
}
if (std::strcmp(msg->data.settings.key, "light_variant") == 0)
{
// Schema changed -> force file reload.
start_simulation_with_reload(true);
return;
}
if (std::strcmp(msg->data.settings.key, "light_mode") == 0)
{
// Switching to simulation mode must always reload schema file.
bool force_reload = (msg->data.settings.type == SETTINGS_TYPE_INT && msg->data.settings.value.int_value == 0);
start_simulation_with_reload(force_reload);
return;
}
if (std::strcmp(msg->data.settings.key, "light_active") == 0)
{
// Power on/off does not force reload; simulator reloads only if not loaded yet.
start_simulation_with_reload(false);
}
}
@@ -265,6 +296,10 @@ void app_task(void *args)
xTaskCreatePinnedToCore(u8g2_mqtt_task, "mqtt_disp", 4096, nullptr, 5, nullptr, tskNO_AFFINITY);
// Create display update task with lower priority (non-blocking main loop)
xTaskCreatePinnedToCore(display_update_task, "display_update", 2048, nullptr, tskIDLE_PRIORITY + 1,
&display_update_task_handle, CONFIG_FREERTOS_NUMBER_OF_CORES - 1);
auto oldTime = esp_timer_get_time();
while (true)
@@ -294,7 +329,11 @@ void app_task(void *args)
last_mqtt_sync = now;
}
u8g2_SendBuffer(&u8g2);
// Signal display task immediately after render to minimize visible latency.
if (display_update_task_handle != nullptr)
{
xTaskNotifyGive(display_update_task_handle);
}
if (xQueueReceive(buttonQueue, &received_signal, pdMS_TO_TICKS(10)) == pdTRUE)
{
+441 -1
View File
@@ -19,7 +19,8 @@
"svelte": "^5.53.5",
"tailwindcss": "^3.3.5",
"vite": "^7.1.2",
"vite-plugin-compression": "^0.5.1"
"vite-plugin-compression": "^0.5.1",
"vitest": "^3.2.4"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1201,6 +1202,24 @@
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1214,6 +1233,121 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^4.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1294,6 +1428,16 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/autoprefixer": {
"version": "10.4.24",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
@@ -1411,6 +1555,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -1442,6 +1596,23 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1459,6 +1630,16 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1568,6 +1749,16 @@
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1628,6 +1819,13 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -1696,6 +1894,26 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1949,6 +2167,13 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@@ -2238,6 +2463,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2376,6 +2608,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2742,6 +2991,13 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2751,6 +3007,33 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -2914,6 +3197,20 @@
"node": ">=0.8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2930,6 +3227,36 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3072,6 +3399,29 @@
}
}
},
"node_modules/vite-node": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.4.1",
"es-module-lexer": "^1.7.0",
"pathe": "^2.0.3",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-compression": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz",
@@ -3107,6 +3457,96 @@
}
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"picomatch": "^4.0.2",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.14",
"tinypool": "^1.1.1",
"tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
"vite-node": "3.2.4",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/debug": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+4 -2
View File
@@ -4,7 +4,8 @@
"scripts": {
"dev": "vite",
"build": "vite build --emptyOutDir",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.1.1",
@@ -13,7 +14,8 @@
"svelte": "^5.53.5",
"tailwindcss": "^3.3.5",
"vite": "^7.1.2",
"vite-plugin-compression": "^0.5.1"
"vite-plugin-compression": "^0.5.1",
"vitest": "^3.2.4"
},
"dependencies": {
"@fontsource/atkinson-hyperlegible": "^5.2.6",
-11
View File
@@ -49,17 +49,6 @@ body {
padding: 12px;
}
#app {
max-width: 900px;
margin: 0 auto;
}
@media (max-width: 600px) {
body {
padding: 6px;
}
}
@supports (padding: max(0px)) {
body {
padding-left: max(12px, env(safe-area-inset-left));
+5 -1
View File
@@ -8,6 +8,8 @@
const routes = {
'/': Index,
'/control': Index,
'/config': Index,
'/captive': Captive,
// Fallback route
'*': Index
@@ -18,12 +20,14 @@
});
</script>
<div class="container mx-auto lg:max-w-2xl">
<Header />
<main class="container mx-auto px-4 py-8">
<main class="py-8">
<Router {routes} />
</main>
{#if $location !== "/captive"}
<Footer />
{/if}
</div>
@@ -0,0 +1,3 @@
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
<slot />
</div>
@@ -1,54 +1,51 @@
<script lang="ts">
import { controlStore, type ControlState } from "../../stores/controlStore";
import LightControl from "./lightControl.svelte";
import ModeControl from "./modeControl.svelte";
import StatusDisplay from "./statusDisplay.svelte";
import { type ControlState, controlStore, createDefaultControlState } from '../../stores/controlStore';
import LightControl from './lightControl.svelte';
import ModeControl from './modeControl.svelte';
import StatusDisplay from './statusDisplay.svelte';
let state = $state<ControlState>({
on: false,
mode: 'day',
schema: 'schema_01.csv',
color: { r: 0, g: 0, b: 0 },
clock: '00:00'
});
let state = $state<ControlState>(createDefaultControlState());
$effect(() => {
return controlStore.subscribe((value) => {
if (value) state = value;
});
});
function setMode(mode: string) {
controlStore.setState({ mode });
function setLight(on: boolean) {
controlStore.setLight({ on });
}
function handleSchemaChange(schema: string) {
controlStore.setState({ schema });
function setMode(mode: string) {
controlStore.setMode({ mode });
}
function setSchema(schema: string) {
controlStore.setSchema({ schema });
}
// Hilfsfunktion für CSS-Farbe
function colorToCss(color: any): string {
if (!color) return "#000";
if (typeof color === "string") return color;
if (!color) return '#000';
if (typeof color === 'string') return color;
if (
typeof color === "object" &&
typeof color === 'object' &&
color.r !== undefined &&
color.g !== undefined &&
color.b !== undefined
) {
return `rgb(${color.r},${color.g},${color.b})`;
}
return "#000";
return '#000';
}
</script>
<div class="space-y-6">
<LightControl lightOn={state.on} />
<LightControl lightOn={state.on} onchange={(e) => setLight(e.detail)} />
<ModeControl
bind:activeSchema={state.schema}
bind:mode={state.mode}
onchangeSchema={(e) => handleSchemaChange(e.detail)}
onchangeSchema={(e) => setSchema(e.detail)}
onchangeMode={(e) => setMode(e.detail)}
/>
@@ -1,13 +1,20 @@
<script lang="ts">
import { t } from "../../i18n/store";
import Toggle from "../common/toggle.svelte";
import { t } from '../../i18n/store';
import Card from '../common/card.svelte';
import Toggle from '../common/toggle.svelte';
export let lightOn = false;
export let thunderOn = false;
let {
lightOn = $bindable(false),
thunderOn = $bindable(false),
onchange,
}: {
lightOn?: boolean;
thunderOn?: boolean;
onchange: (e: CustomEvent<boolean>) => void;
} = $props();
function toggleLight(checked: boolean) {
lightOn = checked;
// TODO: Send command to backend
onchange(new CustomEvent('changeLight', { detail: checked }));
}
function toggleThunder(checked: boolean) {
@@ -16,7 +23,7 @@
}
</script>
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
<Card>
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
💡 {$t("control.light.title")}
</h2>
@@ -35,4 +42,4 @@
/>
</div>
</div>
</div>
</Card>
@@ -2,19 +2,25 @@
import { t } from '../../i18n/store';
import ModeButton from './modeButton.svelte';
import DropDown from '../common/dropDown.svelte';
import Card from '../common/card.svelte';
let { mode = $bindable('simulation'), activeSchema = $bindable('schema_01.csv'), onchangeMode, onchangeSchema }: { mode?: string, activeSchema?: string, onchangeMode?: (e: CustomEvent<string>) => void, onchangeSchema?: (e: CustomEvent<string>) => void } = $props();
let { mode = $bindable('simulation'), activeSchema = $bindable('schema_01.csv'), onchangeMode, onchangeSchema }: {
mode?: string,
activeSchema?: string,
onchangeMode?: (e: CustomEvent<string>) => void,
onchangeSchema?: (e: CustomEvent<string>) => void
} = $props();
let schemas = $derived([
{ 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") }
{ 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') }
]);
function setMode(newMode: string) {
mode = newMode;
if (onchangeMode) {
onchangeMode(new CustomEvent("changeMode", { detail: mode }));
onchangeMode(new CustomEvent('changeMode', { detail: mode }));
}
}
@@ -23,12 +29,12 @@
// If the update came from the WS, activeSchema would be updated directly by the parent.
activeSchema = event.detail;
if (onchangeSchema) {
onchangeSchema(new CustomEvent("changeSchema", { detail: activeSchema }));
onchangeSchema(new CustomEvent('changeSchema', { detail: activeSchema }));
}
}
</script>
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
<Card>
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
🔄 {$t("control.mode.title")}
</h2>
@@ -52,4 +58,4 @@
/>
</div>
{/if}
</div>
</Card>
@@ -1,12 +1,13 @@
<script lang="ts">
import { t } from "../../i18n/store";
import { t } from '../../i18n/store';
import Card from '../common/card.svelte';
export let mode = "simulation";
export let color = "#000000";
export let clock: string | null = "12:34 Uhr";
export let mode = 'simulation';
export let color = '#000000';
export let clock: string | null = '12:34 Uhr';
</script>
<div class="bg-card p-6 rounded-lg border border-border shadow-sm">
<Card>
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
📊 {$t("control.status.title")}
</h2>
@@ -47,4 +48,4 @@
</div>
{/if}
</div>
</div>
</Card>
@@ -21,7 +21,7 @@
}
</script>
<div class="flex flex-wrap justify-between items-center mb-5 gap-2">
<div class="flex flex-wrap justify-between items-center mb-5">
<div>
<Button
ariaLabel="Sprache wechseln"
+20 -19
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { location, replace } from 'svelte-spa-router';
import { t } from "../i18n/store";
import ControlTab from "../components/control/controlTab.svelte";
import ConfigTab from "../components/config/configTab.svelte";
@@ -7,30 +8,30 @@
import TabBar from "../components/common/tabBar.svelte";
import { controlStore } from "../stores/controlStore";
let activeTab = "control";
type Tab = "control" | "config";
const tabToPath: Record<Tab, string> = {
control: "/control",
config: "/config"
};
function pathToTab(path: string): Tab {
return path === "/config" ? "config" : "control";
}
let activeTab = $derived(pathToTab($location));
onMount(() => {
controlStore.fetchState();
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash === "config") {
activeTab = "config";
} else {
activeTab = "control";
// Optional: Default-Route
if ($location === "/") {
replace("/control");
}
};
handleHashChange();
window.addEventListener("hashchange", handleHashChange);
return () => {
window.removeEventListener("hashchange", handleHashChange);
};
});
function setTab(tab: string) {
activeTab = tab;
window.location.hash = tab;
function setTab(tab: Tab) {
replace(tabToPath[tab]);
}
</script>
@@ -50,7 +51,7 @@
<div class="tab-content">
{#if activeTab === "control"}
<ControlTab />
{:else if activeTab === "config"}
{:else}
<ConfigTab />
{/if}
</div>
+46
View File
@@ -0,0 +1,46 @@
type Deferred = {
resolve: () => void;
reject: (error: unknown) => void;
};
export const createLatestOnlySender = <T extends object>(
send: (payload: T) => Promise<void>,
merge: (current: T | null, incoming: T) => T
) => {
let inFlight = false;
let pending: T | null = null;
const waiters: Deferred[] = [];
const flush = async () => {
if (inFlight) return;
inFlight = true;
try {
while (pending) {
const nextPayload = pending;
pending = null;
await send(nextPayload);
}
const done = waiters.splice(0);
done.forEach(({ resolve }) => resolve());
} catch (error) {
const failed = waiters.splice(0);
pending = null;
failed.forEach(({ reject }) => reject(error));
} finally {
inFlight = false;
if (pending) {
void flush();
}
}
};
return (incoming: T): Promise<void> => {
pending = merge(pending, incoming);
return new Promise<void>((resolve, reject) => {
waiters.push({ resolve, reject });
void flush();
});
};
};
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest';
import { get } from 'svelte/store';
type FetchResponse = {
ok: boolean;
status?: number;
statusText?: string;
json: () => Promise<unknown>;
};
const okResponse = (payload: unknown = {}): FetchResponse => ({
ok: true,
json: async () => payload
});
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe('controlStore', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
});
async function setupStore(mock: Mock = vi.fn().mockResolvedValue(okResponse())) {
vi.stubGlobal('fetch', mock);
const { controlStore } = await import('./controlStore');
return { fetchMock: mock, controlStore };
}
it('fetchState merges server data with defaults', async () => {
const { fetchMock, controlStore } = await setupStore(
vi.fn().mockResolvedValue(okResponse({ on: true }))
);
await controlStore.fetchState();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringMatching(/\/api\/light\/status$/),
undefined
);
expect(get(controlStore)).toEqual({
on: true,
mode: 'day',
schema: 'schema_01.csv',
color: { r: 0, g: 0, b: 0 },
clock: '00:00'
});
});
it('update values posts updates and applies returned state', async () => {
const { fetchMock, controlStore } = await setupStore(
vi.fn().mockResolvedValueOnce(okResponse({}))
);
await controlStore.setMode({ mode: 'night' });
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/light\/mode$/), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'night' })
});
expect(get(controlStore)).toEqual({
on: false,
mode: 'night',
schema: 'schema_01.csv',
color: { r: 0, g: 0, b: 0 },
clock: '00:00'
});
});
it('fetchState throws on non-ok response', async () => {
const { controlStore } = await setupStore(
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => ({})
})
);
await expect(controlStore.fetchState()).rejects.toThrow(
'Request failed: 500 Internal Server Error'
);
});
it('setLight posts to /api/light/power', async () => {
const { fetchMock, controlStore } = await setupStore();
await controlStore.setLight({ on: true });
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/light\/power$/), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: true })
});
});
it('setMode posts to /api/light/mode', async () => {
const { fetchMock, controlStore } = await setupStore();
await controlStore.setMode({ mode: 'night' });
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/light\/mode$/), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'night' })
});
});
it('setLight coalesces pending updates while request is in flight', async () => {
const first = deferred<FetchResponse>();
const { fetchMock, controlStore } = await setupStore(
vi.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValue(okResponse())
);
const p1 = controlStore.setLight({ on: true });
const p2 = controlStore.setLight({ on: false });
const p3 = controlStore.setLight({ on: true });
expect(fetchMock).toHaveBeenCalledTimes(1);
first.resolve(okResponse());
await Promise.all([p1, p2, p3]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(2, expect.stringMatching(/\/api\/light\/power$/), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: true })
});
});
it('setLight with true,false,true,false sends first and last payload only', async () => {
const first = deferred<FetchResponse>();
const { fetchMock, controlStore } = await setupStore(
vi.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValue(okResponse())
);
const p1 = controlStore.setLight({ on: true });
const p2 = controlStore.setLight({ on: false });
const p3 = controlStore.setLight({ on: true });
const p4 = controlStore.setLight({ on: false });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenNthCalledWith(1, expect.stringMatching(/\/api\/light\/power$/), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: true })
});
first.resolve(okResponse());
await Promise.all([p1, p2, p3, p4]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(2, expect.stringMatching(/\/api\/light\/power$/), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ on: false })
});
});
});
+202 -47
View File
@@ -1,4 +1,6 @@
import { writable } from 'svelte/store';
import { createLogger } from './logger';
import { createLatestOnlySender } from './common';
// Types for state and REST/WebSocket messages
export interface ControlState {
@@ -7,85 +9,238 @@ export interface ControlState {
schema?: string;
color?: { r: number; g: number; b: number };
clock?: string;
[key: string]: any;
}
const createControlStore = () => {
const store = writable<ControlState>({
interface StatusMessage extends Partial<ControlState> {
type: 'status';
}
export const DEFAULT_CONTROL_STATE: ControlState = {
on: false,
mode: 'day',
schema: 'schema_01.csv',
color: { r: 0, g: 0, b: 0 },
clock: '00:00'
});
const { subscribe, set } = store;
};
// Centralized host and URL configuration
const host = import.meta.env.DEV
? 'system-control.local'
: typeof window !== 'undefined'
? window.location.host
: '';
const baseUrl = import.meta.env.DEV ? `http://${host}` : '';
export const createDefaultControlState = (): ControlState => ({
...DEFAULT_CONTROL_STATE,
color: DEFAULT_CONTROL_STATE.color ? { ...DEFAULT_CONTROL_STATE.color } : undefined
});
const STATUS_ENDPOINT = '/api/light/status';
const LIGHT_ENDPOINT = '/api/light/power';
const MODE_ENDPOINT = '/api/light/mode';
const SCHEMA_ENDPOINT = '/api/light/schema';
const WS_ENDPOINT = '/ws';
const WS_RECONNECT_DELAY_MS = 3000;
const isBrowser = typeof window !== 'undefined';
const resolveHost = () => {
if (import.meta.env.DEV) return 'system-control.local';
return isBrowser ? window.location.host : '';
};
const buildBaseUrl = (host: string) => {
if (!host) return '';
const protocol = isBrowser ? window.location.protocol : import.meta.env.DEV ? 'http:' : 'https:';
return `${protocol}//${host}`;
};
const buildWebSocketUrl = (host: string) => {
if (!isBrowser) return '';
const wsProtocol =
window.location.protocol === 'https:' && !import.meta.env.DEV ? 'wss:' : 'ws:';
return `${wsProtocol}//${host}${WS_ENDPOINT}`;
};
const isStatusMessage = (value: unknown): value is StatusMessage => {
if (!value || typeof value !== 'object') return false;
return (value as { type?: unknown }).type === 'status';
};
const parseJson = (raw: string): unknown => {
try {
return JSON.parse(raw);
} catch {
return null;
}
};
const createControlStore = () => {
const log = createLogger('controlStore');
const store = writable<ControlState>(createDefaultControlState());
const { subscribe: internalSubscribe, set } = store;
type StoreSubscribe = typeof internalSubscribe;
type StoreRun = Parameters<StoreSubscribe>[0];
type StoreInvalidate = Parameters<StoreSubscribe>[1];
const host = resolveHost();
const baseUrl = buildBaseUrl(host);
const wsUrl = buildWebSocketUrl(host);
let ws: WebSocket | null = null;
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let shouldReconnect = true;
let subscriberCount = 0;
async function fetchState() {
const res = await fetch(`${baseUrl}/api/light/status`);
if (!res.ok) throw new Error('Failed to fetch state');
const data = await res.json();
set(data);
const applyState = (nextState: Partial<ControlState>) => {
set({ ...createDefaultControlState(), ...nextState });
};
const clearReconnectTimer = () => {
if (!wsReconnectTimer) return;
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
};
const scheduleReconnect = () => {
if (!shouldReconnect || wsReconnectTimer) return;
log.info('Scheduling WebSocket reconnect', { delayMs: WS_RECONNECT_DELAY_MS });
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
connectWebSocket();
}, WS_RECONNECT_DELAY_MS);
};
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
log.debug('HTTP request', { path, method: init?.method ?? 'GET' });
const res = await fetch(`${baseUrl}${path}`, init);
if (!res.ok) {
log.warn('HTTP request failed', {
path,
status: res.status,
statusText: res.statusText
});
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
log.debug('HTTP request succeeded', { path, status: res.status });
return (await res.json()) as T;
}
async function setState(partial: Partial<ControlState>) {
const res = await fetch(`${baseUrl}/api/status`, {
async function fetchState() {
const data = await requestJson<Partial<ControlState>>(STATUS_ENDPOINT);
applyState(data);
}
const sendLightLatestOnly = createLatestOnlySender<Partial<ControlState>>(
async (payload) => {
await requestJson<Partial<ControlState>>(LIGHT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(partial)
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Failed to update state');
const data = await res.json();
set(data);
applyState(payload);
},
(current, incoming) => ({ ...(current ?? {}), ...incoming })
);
const sendModeLatestOnly = createLatestOnlySender<Partial<ControlState>>(
async (payload) => {
await requestJson<Partial<ControlState>>(MODE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
applyState(payload);
},
(current, incoming) => ({ ...(current ?? {}), ...incoming })
);
const sendSchemaLatestOnly = createLatestOnlySender<Partial<ControlState>>(
async (payload) => {
await requestJson<Partial<ControlState>>(SCHEMA_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
applyState(payload);
},
(current, incoming) => ({ ...(current ?? {}), ...incoming })
);
async function setLight(partial: Partial<ControlState>) {
await sendLightLatestOnly(partial);
}
async function setMode(partial: Partial<ControlState>) {
await sendModeLatestOnly(partial);
}
async function setSchema(partial: Partial<ControlState>) {
await sendSchemaLatestOnly(partial);
}
function connectWebSocket() {
if (typeof window === 'undefined') return;
if (!isBrowser || ws || !wsUrl) return;
log.info('Connecting WebSocket', { url: wsUrl });
const wsProtocol =
window.location.protocol === 'https:' && !import.meta.env.DEV ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${host}/ws`;
const socket = new WebSocket(wsUrl);
ws = socket;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
ws?.send(JSON.stringify({ type: 'getStatus' }));
socket.onopen = () => {
clearReconnectTimer();
log.info('WebSocket connected');
socket.send(JSON.stringify({ type: 'getStatus' }));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'status') set(data);
} catch (e) {
// ignore
socket.onmessage = (event) => {
const message = parseJson(event.data);
if (!isStatusMessage(message)) {
log.debug('Ignoring non-status WebSocket message');
return;
}
const { type: _type, ...state } = message;
log.debug('Applying status update from WebSocket');
applyState(state);
};
ws.onclose = () => {
ws = null;
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
socket.onclose = () => {
if (ws === socket) ws = null;
log.warn('WebSocket closed');
scheduleReconnect();
};
ws.onerror = () => {
socket.onerror = () => {
log.error('WebSocket error');
socket.close();
};
}
function disconnectWebSocket() {
shouldReconnect = false;
clearReconnectTimer();
ws?.close();
};
ws = null;
}
if (typeof window !== 'undefined') connectWebSocket();
const subscribe: typeof store.subscribe = (
run: StoreRun,
invalidate?: StoreInvalidate
) => {
subscriberCount += 1;
if (subscriberCount === 1) {
log.debug('First subscriber attached - starting WebSocket');
shouldReconnect = true;
connectWebSocket();
}
const unsubscribe = internalSubscribe(run, invalidate);
return () => {
unsubscribe();
subscriberCount -= 1;
if (subscriberCount === 0) {
log.debug('Last subscriber removed - stopping WebSocket');
disconnectWebSocket();
}
};
};
return {
...store,
subscribe,
fetchState,
setState
setLight,
setMode,
setSchema
};
};
+32
View File
@@ -0,0 +1,32 @@
type LogMeta = unknown;
type LoggerFn = (message: string, meta?: LogMeta) => void;
const DEFAULT_DEV_LOGGING_ENABLED =
import.meta.env.DEV &&
import.meta.env.MODE !== 'test' &&
import.meta.env.VITE_DEV_LOGGING !== 'false';
let devLoggingEnabled = DEFAULT_DEV_LOGGING_ENABLED;
const emit = (fn: (...data: unknown[]) => void, scope: string, message: string, meta?: LogMeta) => {
if (!devLoggingEnabled) return;
if (meta === undefined) {
fn(`[${scope}] ${message}`);
return;
}
fn(`[${scope}] ${message}`, meta);
};
export const setDevLoggingEnabled = (enabled: boolean) => {
devLoggingEnabled = enabled;
};
export const createLogger = (scope: string): Record<'debug' | 'info' | 'warn' | 'error', LoggerFn> => ({
debug: (message, meta) => emit(console.debug, scope, message, meta),
info: (message, meta) => emit(console.info, scope, message, meta),
warn: (message, meta) => emit(console.warn, scope, message, meta),
error: (message, meta) => emit(console.error, scope, message, meta)
});
+2 -1
View File
@@ -14,7 +14,8 @@
"esModuleInterop": true,
"skipLibCheck": true,
"allowJs": true,
"checkJs": true
"checkJs": true,
"noEmit": true
},
"include": [
"src/**/*.d.ts",