From 829e914a7923008fbf79fa4fbc17bab27165b5b8 Mon Sep 17 00:00:00 2001 From: Peter Siegmund Date: Tue, 17 Mar 2026 19:45:28 +0100 Subject: [PATCH] latest website and FW changes Signed-off-by: Peter Siegmund --- firmware/.github/copilot-instructions.md | 51 ++ .../instructions/api-server.instructions.md | 25 + .../instructions/website.instructions.md | 17 + .../prompts/add-api-endpoint.prompt.md | 43 ++ .../prompts/website-change-check.prompt.md | 30 ++ .../components/api-server/src/api_handlers.c | 72 ++- .../message-manager/src/message_manager.c | 2 +- .../src/persistence_manager.c | 64 ++- .../components/simulator/include/simulator.h | 1 + .../components/simulator/src/simulator.cpp | 47 +- firmware/main/src/app_task.cpp | 51 +- firmware/website/package-lock.json | 442 +++++++++++++++++- firmware/website/package.json | 6 +- firmware/website/src/app.css | 11 - firmware/website/src/app.svelte | 18 +- .../website/src/components/common/card.svelte | 3 + .../src/components/control/controlTab.svelte | 41 +- .../components/control/lightControl.svelte | 23 +- .../src/components/control/modeControl.svelte | 64 +-- .../components/control/statusDisplay.svelte | 13 +- firmware/website/src/components/header.svelte | 2 +- firmware/website/src/routes/index.svelte | 39 +- firmware/website/src/stores/common.ts | 46 ++ .../website/src/stores/controlStore.test.ts | 170 +++++++ firmware/website/src/stores/controlStore.ts | 265 ++++++++--- firmware/website/src/stores/logger.ts | 32 ++ firmware/website/tsconfig.json | 3 +- 27 files changed, 1375 insertions(+), 206 deletions(-) create mode 100644 firmware/.github/copilot-instructions.md create mode 100644 firmware/.github/instructions/api-server.instructions.md create mode 100644 firmware/.github/instructions/website.instructions.md create mode 100644 firmware/.github/prompts/add-api-endpoint.prompt.md create mode 100644 firmware/.github/prompts/website-change-check.prompt.md create mode 100644 firmware/website/src/components/common/card.svelte create mode 100644 firmware/website/src/stores/common.ts create mode 100644 firmware/website/src/stores/controlStore.test.ts create mode 100644 firmware/website/src/stores/logger.ts diff --git a/firmware/.github/copilot-instructions.md b/firmware/.github/copilot-instructions.md new file mode 100644 index 0000000..90a692a --- /dev/null +++ b/firmware/.github/copilot-instructions.md @@ -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 flash` +- Flash + monitor: `idf.py -p 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` diff --git a/firmware/.github/instructions/api-server.instructions.md b/firmware/.github/instructions/api-server.instructions.md new file mode 100644 index 0000000..6d16bcb --- /dev/null +++ b/firmware/.github/instructions/api-server.instructions.md @@ -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. diff --git a/firmware/.github/instructions/website.instructions.md b/firmware/.github/instructions/website.instructions.md new file mode 100644 index 0000000..b1fd9fb --- /dev/null +++ b/firmware/.github/instructions/website.instructions.md @@ -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. diff --git a/firmware/.github/prompts/add-api-endpoint.prompt.md b/firmware/.github/prompts/add-api-endpoint.prompt.md new file mode 100644 index 0000000..27cf345 --- /dev/null +++ b/firmware/.github/prompts/add-api-endpoint.prompt.md @@ -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); +``` diff --git a/firmware/.github/prompts/website-change-check.prompt.md b/firmware/.github/prompts/website-change-check.prompt.md new file mode 100644 index 0000000..5d9da82 --- /dev/null +++ b/firmware/.github/prompts/website-change-check.prompt.md @@ -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) diff --git a/firmware/components/api-server/src/api_handlers.c b/firmware/components/api-server/src/api_handlers.c index a474e04..96b455c 100644 --- a/firmware/components/api-server/src/api_handlers.c +++ b/firmware/components/api-server/src/api_handlers.c @@ -86,38 +86,47 @@ 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(); - for (int i = 0; i < ap_num; i++) + + if (ap_num > 0) { - if (ap_list[i].ssid[0] != '\0') + wifi_ap_record_t *ap_list = heap_caps_calloc(ap_num, sizeof(wifi_ap_record_t), MALLOC_CAP_DEFAULT); + if (ap_list) { - cJSON *entry = cJSON_CreateObject(); - cJSON_AddStringToObject(entry, "ssid", (const char *)ap_list[i].ssid); - cJSON_AddNumberToObject(entry, "rssi", ap_list[i].rssi); - bool secure = ap_list[i].authmode != WIFI_AUTH_OPEN; - cJSON_AddBoolToObject(entry, "secure", secure); - cJSON_AddItemToArray(json, entry); + 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') + { + cJSON *entry = cJSON_CreateObject(); + cJSON_AddStringToObject(entry, "ssid", (const char *)ap_list[i].ssid); + cJSON_AddNumberToObject(entry, "rssi", ap_list[i].rssi); + bool secure = ap_list[i].authmode != WIFI_AUTH_OPEN; + cJSON_AddBoolToObject(entry, "secure", secure); + 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; diff --git a/firmware/components/message-manager/src/message_manager.c b/firmware/components/message-manager/src/message_manager.c index 8ee98a5..8dc76d7 100644 --- a/firmware/components/message-manager/src/message_manager.c +++ b/firmware/components/message-manager/src/message_manager.c @@ -11,7 +11,7 @@ #include #include -#define MESSAGE_QUEUE_LENGTH 16 +#define MESSAGE_QUEUE_LENGTH 32 #define MESSAGE_QUEUE_ITEM_SIZE sizeof(message_t) static const char *TAG = "message_manager"; diff --git a/firmware/components/persistence-manager/src/persistence_manager.c b/firmware/components/persistence-manager/src/persistence_manager.c index 51b8b14..0419489 100644 --- a/firmware/components/persistence-manager/src/persistence_manager.c +++ b/firmware/components/persistence-manager/src/persistence_manager.c @@ -3,6 +3,54 @@ #include #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; } diff --git a/firmware/components/simulator/include/simulator.h b/firmware/components/simulator/include/simulator.h index 9614a2a..62dbe55 100644 --- a/firmware/components/simulator/include/simulator.h +++ b/firmware/components/simulator/include/simulator.h @@ -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 } diff --git a/firmware/components/simulator/src/simulator.cpp b/firmware/components/simulator/src/simulator.cpp index 6c3298c..34ba85c 100644 --- a/firmware/components/simulator/src/simulator.cpp +++ b/firmware/components/simulator/src/simulator.cpp @@ -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,22 +142,38 @@ 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); - snprintf(filename, sizeof(filename), "schema_%02d.csv", variant); - load_file(filename); 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); + 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 if (head == NULL) @@ -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); +} diff --git a/firmware/main/src/app_task.cpp b/firmware/main/src/app_task.cpp index 450696f..6be300f 100644 --- a/firmware/main/src/app_task.cpp +++ b/firmware/main/src/app_task.cpp @@ -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) { diff --git a/firmware/website/package-lock.json b/firmware/website/package-lock.json index 19bbb08..a8779ba 100644 --- a/firmware/website/package-lock.json +++ b/firmware/website/package-lock.json @@ -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", diff --git a/firmware/website/package.json b/firmware/website/package.json index 9cdd5d3..80c7ed4 100644 --- a/firmware/website/package.json +++ b/firmware/website/package.json @@ -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", diff --git a/firmware/website/src/app.css b/firmware/website/src/app.css index 6f683b8..9e1c493 100644 --- a/firmware/website/src/app.css +++ b/firmware/website/src/app.css @@ -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)); diff --git a/firmware/website/src/app.svelte b/firmware/website/src/app.svelte index d1de190..486730f 100644 --- a/firmware/website/src/app.svelte +++ b/firmware/website/src/app.svelte @@ -8,6 +8,8 @@ const routes = { '/': Index, + '/control': Index, + '/config': Index, '/captive': Captive, // Fallback route '*': Index @@ -18,12 +20,14 @@ }); -
+
+
-
- -
+
+ +
-{#if $location !== "/captive"} -
-{/if} + {#if $location !== "/captive"} +
+ {/if} +
diff --git a/firmware/website/src/components/common/card.svelte b/firmware/website/src/components/common/card.svelte new file mode 100644 index 0000000..44ccf30 --- /dev/null +++ b/firmware/website/src/components/common/card.svelte @@ -0,0 +1,3 @@ +
+ +
diff --git a/firmware/website/src/components/control/controlTab.svelte b/firmware/website/src/components/control/controlTab.svelte index ce903ef..3c5a3f8 100644 --- a/firmware/website/src/components/control/controlTab.svelte +++ b/firmware/website/src/components/control/controlTab.svelte @@ -1,54 +1,51 @@
- + setLight(e.detail)} /> handleSchemaChange(e.detail)} + onchangeSchema={(e) => setSchema(e.detail)} onchangeMode={(e) => setMode(e.detail)} /> diff --git a/firmware/website/src/components/control/lightControl.svelte b/firmware/website/src/components/control/lightControl.svelte index 9a5dcf0..c4ea6a8 100644 --- a/firmware/website/src/components/control/lightControl.svelte +++ b/firmware/website/src/components/control/lightControl.svelte @@ -1,13 +1,20 @@ -
+

💡 {$t("control.light.title")}

@@ -35,4 +42,4 @@ />
- + diff --git a/firmware/website/src/components/control/modeControl.svelte b/firmware/website/src/components/control/modeControl.svelte index 7ef0ed6..8068dc6 100644 --- a/firmware/website/src/components/control/modeControl.svelte +++ b/firmware/website/src/components/control/modeControl.svelte @@ -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) => void, onchangeSchema?: (e: CustomEvent) => void } = $props(); + let { mode = $bindable('simulation'), activeSchema = $bindable('schema_01.csv'), onchangeMode, onchangeSchema }: { + mode?: string, + activeSchema?: string, + onchangeMode?: (e: CustomEvent) => void, + onchangeSchema?: (e: CustomEvent) => 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,33 +29,33 @@ // 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 })); } } -
-

- 🔄 {$t("control.mode.title")} -

+ +

+ 🔄 {$t("control.mode.title")} +

-
- setMode('day')} /> - setMode('night')} /> - setMode('simulation')} /> +
+ setMode('day')} /> + setMode('night')} /> + setMode('simulation')} /> +
+ +{#if mode === 'simulation'} +
+ + +
- - {#if mode === 'simulation'} -
- - - -
- {/if} -
\ No newline at end of file +{/if} +
diff --git a/firmware/website/src/components/control/statusDisplay.svelte b/firmware/website/src/components/control/statusDisplay.svelte index 7bdc6c7..1eef102 100644 --- a/firmware/website/src/components/control/statusDisplay.svelte +++ b/firmware/website/src/components/control/statusDisplay.svelte @@ -1,12 +1,13 @@ -
+

📊 {$t("control.status.title")}

@@ -47,4 +48,4 @@
{/if}
- + diff --git a/firmware/website/src/components/header.svelte b/firmware/website/src/components/header.svelte index 8d92491..32b0b06 100644 --- a/firmware/website/src/components/header.svelte +++ b/firmware/website/src/components/header.svelte @@ -21,7 +21,7 @@ } -
+