latest website and FW changes
Signed-off-by: Peter Siegmund <developer@mars3142.org>
This commit is contained in:
+51
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Generated
+441
-1
@@ -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,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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
const routes = {
|
||||
'/': Index,
|
||||
'/control': Index,
|
||||
'/config': Index,
|
||||
'/captive': Captive,
|
||||
// Fallback route
|
||||
'*': Index
|
||||
@@ -18,12 +20,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
<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>
|
||||
</main>
|
||||
|
||||
{#if $location !== "/captive"}
|
||||
{#if $location !== "/captive"}
|
||||
<Footer />
|
||||
{/if}
|
||||
{/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,24 +29,24 @@
|
||||
// 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">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Card>
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
🔄 {$t("control.mode.title")}
|
||||
</h2>
|
||||
</h2>
|
||||
|
||||
<div class="flex gap-2 mb-6">
|
||||
<div class="flex gap-2 mb-6">
|
||||
<ModeButton active={mode === 'day'} icon="☀️" label={$t('mode.day')} onClick={() => setMode('day')} />
|
||||
<ModeButton active={mode === 'night'} icon="🌙" label={$t('mode.night')} onClick={() => setMode('night')} />
|
||||
<ModeButton active={mode === 'simulation'} icon="🔄" label={$t('mode.simulation')}
|
||||
onClick={() => setMode('simulation')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mode === 'simulation'}
|
||||
{#if mode === 'simulation'}
|
||||
<div class="p-4 bg-background rounded-md border border-border">
|
||||
<label for="active-schema" class="block text-sm font-medium mb-2">{$t("control.schema.active")}</label>
|
||||
|
||||
@@ -51,5 +57,5 @@
|
||||
onchange={handleSchemaChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true
|
||||
"checkJs": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user